diff --git a/api/models.py b/api/models.py
index e32228c5..3c0f660b 100644
--- a/api/models.py
+++ b/api/models.py
@@ -7989,6 +7989,7 @@ _SESSION_MESSAGE_DISPLAY_METADATA_KEYS = (
"_turnTps",
"_turnUsage",
"_firstTokenMs",
+ "_usedModel",
"_gatewayRouting",
"_statusCard",
"_anchor_stream_id",
diff --git a/api/streaming.py b/api/streaming.py
index 115b6fe5..c5329ae3 100644
--- a/api/streaming.py
+++ b/api/streaming.py
@@ -9936,6 +9936,9 @@ def _run_agent_streaming(
_ttft_ms = meter().get_ttft_ms(stream_id)
if _ttft_ms is not None:
_dm['_firstTokenMs'] = _ttft_ms
+ _used_model = resolved_model or model
+ if _used_model:
+ _dm['_usedModel'] = _used_model
break
# Persist context window data on the session so the context-ring
# indicator survives a page reload (#1318). Must run BEFORE
@@ -10323,6 +10326,9 @@ def _run_agent_streaming(
_ttft_ms = meter().get_ttft_ms(stream_id)
if _ttft_ms is not None:
usage['ttft_ms'] = _ttft_ms
+ _used_model = resolved_model or model
+ if _used_model:
+ usage['used_model'] = _used_model
# Include context window data from the agent's compressor for the UI indicator.
# The session-level persistence happens above (before s.save()) so the values
# survive a page reload; this block only populates the live SSE usage payload.
diff --git a/static/style.css b/static/style.css
index a2538e28..2516a88a 100644
--- a/static/style.css
+++ b/static/style.css
@@ -4411,6 +4411,7 @@ body.resizing .sidebar{transition:none!important;}
.transparent-turn-footer .lf-time{color:var(--text);}
.transparent-turn-footer .lf-sep{opacity:.4;}
.transparent-turn-footer .lf-status{color:var(--muted);opacity:.85;}
+.transparent-turn-footer .lf-model{color:var(--muted);opacity:.85;}
.transparent-turn-footer .lf-ttft{color:var(--accent);opacity:.9;}
.transparent-turn-footer .lf-tokens{font-variant-numeric:tabular-nums;}
@@ -6110,6 +6111,7 @@ main.main > #mainPlugin{display:none;}
.msg-usage-inline,
.msg-duration-inline,
.msg-gateway-inline,
+.msg-used-model-inline,
.gateway-failover-inline,
.msg-model-warning-inline {
font-size: 11px;
diff --git a/static/ui.js b/static/ui.js
index 3e92cd34..ff7a75ab 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -7030,6 +7030,14 @@ function _formatGatewayModelLabel(modelId,labelText,routing){
const via=_gatewayRoutingLabel(routing);
return via?`${base} ${via}`:base;
}
+function _usedModelTurnChipLabel(msg){
+ if(!msg)return'';
+ const routing=msg._gatewayRouting||null;
+ if(routing&&String(routing.used_model||'').trim())return'';
+ const usedModel=String(msg._usedModel||'').trim();
+ if(!usedModel)return'';
+ return _compactComposerModelChipLabel(usedModel,getModelLabel(usedModel));
+}
function _gatewayRoutingFailoverText(routing){
if(!routing||!routing.has_failover)return'';
const attempts=Array.isArray(routing.routing)?routing.routing:[];
@@ -11754,9 +11762,10 @@ function _applyTransparentRowFading(turn){
// Mirrors the live run-status line for settled turns in transparent
// mode. Shows duration, first-token time, token usage, and final status.
// Only rendered for turns that have transparent event rows.
-function _transparentTurnFooterHtml(durationText, ttftText, tokensText, statusText){
+function _transparentTurnFooterHtml(durationText, modelText, ttftText, tokensText, statusText){
const parts=[];
if(durationText) parts.push(`${esc(durationText)}`);
+ if(modelText) parts.push(`${esc(modelText)}`);
if(ttftText) parts.push(`TTFT ${esc(ttftText)}`);
if(tokensText) parts.push(`${esc(tokensText)}`);
if(statusText) parts.push(`${esc(statusText)}`);
@@ -11775,10 +11784,11 @@ function _renderTransparentTurnFooter(turn, opts){
return;
}
const durationText=opts&&opts.durationText||'';
+ const modelText=opts&&opts.modelText||'';
const ttftText=opts&&opts.ttftText||'';
const tokensText=opts&&opts.tokensText||'';
const statusText=opts&&opts.statusText||(t('done')||'Done');
- const html=_transparentTurnFooterHtml(durationText, ttftText, tokensText, statusText);
+ const html=_transparentTurnFooterHtml(durationText, modelText, ttftText, tokensText, statusText);
let footer=turn.querySelector('.transparent-turn-footer');
if(!html){
if(footer) footer.remove();
@@ -16595,12 +16605,13 @@ function renderMessages(options){
// Worklog above the final answer.
const compactWorklogForMessage=isCompactWorklogMode()&&(toolCallAssistantIdxs.has(mi)||assistantThinking.has(mi));
const durationText=compactWorklogForMessage?'':_formatTurnDuration(msg._turnDuration);
- if(!hasTurnUsage&&!durationText&&!gatewayText&&!failoverText&&!modelWarningText) continue;
+ const usedModelText=_usedModelTurnChipLabel(msg);
+ if(!hasTurnUsage&&!durationText&&!gatewayText&&!failoverText&&!modelWarningText&&!usedModelText) continue;
const seg=assistantSegments.get(mi);
const row=seg?seg.closest('.assistant-turn'):null;
const footerRows=row?row.querySelectorAll('.msg-foot'):[];
const targetFoot=footerRows.length?footerRows[footerRows.length-1]:null;
- if(!targetFoot||targetFoot.querySelector('.msg-usage-inline,.msg-duration-inline,.msg-gateway-inline,.gateway-failover-inline,.msg-model-warning-inline')) continue;
+ if(!targetFoot||targetFoot.querySelector('.msg-usage-inline,.msg-duration-inline,.msg-gateway-inline,.gateway-failover-inline,.msg-model-warning-inline,.msg-used-model-inline')) continue;
const fragments=[];
if(modelWarningText){
const warning=document.createElement('span');
@@ -16620,6 +16631,12 @@ function renderMessages(options){
gateway.textContent=gatewayText;
fragments.push(gateway);
}
+ if(usedModelText){
+ const usedModel=document.createElement('span');
+ usedModel.className='msg-used-model-inline';
+ usedModel.textContent=usedModelText;
+ fragments.push(usedModel);
+ }
if(durationText){
const duration=document.createElement('span');
duration.className='msg-duration-inline';
@@ -16676,6 +16693,7 @@ function renderMessages(options){
// Find the corresponding message to read duration/usage.
const seg=turn.querySelector('.assistant-segment');
let durationText='';
+ let modelText='';
let ttftText='';
let tokensText='';
if(seg){
@@ -16683,6 +16701,7 @@ function renderMessages(options){
if(mi!=null){
const msg=S.messages[Number(mi)]||{};
if(msg._turnDuration!=null) durationText=_formatTurnDuration(msg._turnDuration);
+ modelText=_usedModelTurnChipLabel(msg);
if(msg._firstTokenMs!=null) ttftText=_formatFirstToken(msg._firstTokenMs);
if(msg._turnUsage){
const inTok=msg._turnUsage.input_tokens||0;
@@ -16693,6 +16712,7 @@ function renderMessages(options){
}
_renderTransparentTurnFooter(turn,{
durationText,
+ modelText,
ttftText,
tokensText,
statusText: t('done')||'Done',
diff --git a/tests/test_issue6068_used_model_footer.py b/tests/test_issue6068_used_model_footer.py
new file mode 100644
index 00000000..1f52f22d
--- /dev/null
+++ b/tests/test_issue6068_used_model_footer.py
@@ -0,0 +1,55 @@
+"""Regression coverage for #6068 per-turn used-model footer instrumentation."""
+
+from pathlib import Path
+
+from api.models import Session
+
+
+REPO = Path(__file__).resolve().parents[1]
+STREAMING_PY = (REPO / "api" / "streaming.py").read_text(encoding="utf-8")
+MODELS_PY = (REPO / "api" / "models.py").read_text(encoding="utf-8")
+UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
+STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
+
+
+def test_streaming_stamps_used_model_on_assistant_message_and_usage_payload():
+ assert "_dm['_usedModel'] = _used_model" in STREAMING_PY
+ assert "_used_model = resolved_model or model" in STREAMING_PY
+ assert "usage['used_model'] = _used_model" in STREAMING_PY
+
+
+def test_models_allowlist_round_trips_used_model_across_save_reload():
+ assert '"_usedModel"' in MODELS_PY
+ assert "_usedModel" in MODELS_PY.split("_SESSION_MESSAGE_DISPLAY_METADATA_KEYS")[1].split(")")[0]
+
+ session = Session(session_id="6068usedmodel", title="Used model")
+ session.messages = [
+ {
+ "role": "assistant",
+ "content": "done",
+ "_firstTokenMs": 250,
+ "_usedModel": "gpt-5-mini",
+ },
+ ]
+ session.save()
+
+ reloaded = Session.load("6068usedmodel")
+ assert reloaded.messages[-1]["_usedModel"] == "gpt-5-mini"
+ assert reloaded.messages[-1]["_firstTokenMs"] == 250
+
+
+def test_settled_footer_renders_used_model_chip_and_suppresses_gateway_duplicate():
+ assert "function _usedModelTurnChipLabel" in UI_JS
+ assert "msg-used-model-inline" in UI_JS
+ assert "_usedModelTurnChipLabel(msg)" in UI_JS
+ assert "routing.used_model" in UI_JS
+ assert "msg._usedModel" in UI_JS
+ assert "_compactComposerModelChipLabel(usedModel,getModelLabel(usedModel))" in UI_JS
+ assert ".msg-used-model-inline" in STYLE_CSS
+
+
+def test_transparent_turn_footer_includes_model_between_duration_and_ttft():
+ assert "function _transparentTurnFooterHtml(durationText, modelText, ttftText, tokensText, statusText)" in UI_JS
+ assert 'class="lf-model"' in UI_JS
+ assert "modelText=_usedModelTurnChipLabel(msg)" in UI_JS
+ assert ".transparent-turn-footer .lf-model" in STYLE_CSS
\ No newline at end of file