fix: stamp post-run agent.model + one model label per turn (#6068 gate)

Addresses the three gate-review blockers on #6113:

- Fallback attribution: read the served model from agent.model AFTER the
  run (the agent mutates it when a fallback fires) instead of the pre-run
  resolved_model, for both the persisted _usedModel and SSE used_model.
- Gateway double-label: suppress the additive chip whenever
  _gatewayRouting is present, and pass msg._usedModel to
  _formatGatewayModelLabel as its model fallback when routing omits
  used_model.
- Transparent double-label: skip the generic .msg-used-model-inline chip
  when the transparent turn footer owns the model label (.lf-model).

Also per Greptile: settled footer now orders the model chip after the
duration (elapsed - model - ...) to match the transparent footer, and the
test file gains its trailing newline.
This commit is contained in:
b3nw 2026-07-19 17:16:08 +00:00
parent 1598940091
commit 9bf876598d
3 changed files with 60 additions and 13 deletions

View file

@ -9920,6 +9920,11 @@ def _run_agent_streaming(
requested_model=resolved_model or model,
requested_provider=resolved_provider,
)
# #6068: the served model must be read AFTER agent.run — the agent
# mutates agent.model when a fallback fires, so the pre-run
# resolved_model would mis-attribute exactly the turns where
# attribution matters most.
_used_model = getattr(agent, 'model', None) or resolved_model or model
if _gateway_routing:
s.gateway_routing = _gateway_routing
_history = list(getattr(s, 'gateway_routing_history', None) or [])
@ -9936,7 +9941,6 @@ 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
@ -10326,7 +10330,6 @@ 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.

View file

@ -7032,8 +7032,11 @@ function _formatGatewayModelLabel(modelId,labelText,routing){
}
function _usedModelTurnChipLabel(msg){
if(!msg)return'';
const routing=msg._gatewayRouting||null;
if(routing&&String(routing.used_model||'').trim())return'';
// Gateway turns own their model label via _formatGatewayModelLabel (which
// falls back to msg._usedModel when routing omits used_model), so suppress
// the additive chip whenever routing metadata is present — not only when
// routing.used_model is set — to guarantee one model label per turn.
if(msg._gatewayRouting)return'';
const usedModel=String(msg._usedModel||'').trim();
if(!usedModel)return'';
return _compactComposerModelChipLabel(usedModel,getModelLabel(usedModel));
@ -16596,7 +16599,7 @@ function renderMessages(options){
const msg=S.messages[mi]||{};
if(msg.role!=='assistant') continue;
const routing=msg._gatewayRouting||null;
const gatewayText=_formatGatewayModelLabel(S.session&&S.session.model||'', '', routing);
const gatewayText=_formatGatewayModelLabel(String(msg._usedModel||'').trim()||(S.session&&S.session.model)||'', '', routing);
const failoverText=_gatewayRoutingFailoverText(routing);
const modelWarningText=_gatewayModelWarningText(routing);
const hasTurnUsage=!!msg._turnUsage;
@ -16631,18 +16634,26 @@ 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';
duration.textContent=`Done in ${durationText}`;
fragments.push(duration);
}
// The transparent turn footer owns the model label (.lf-model) whenever
// the turn has transparent event rows — skip the generic chip there so
// exactly one model label renders per turn. Model sits after duration to
// match the transparent footer order (elapsed · model · …).
const _transparentFooterOwnsModel=usedModelText&&isTransparentStream()&&row&&(()=>{
const blocks=_assistantTurnBlocks(row);
return !!(blocks&&blocks.querySelector(':scope > .transparent-event-row'));
})();
if(usedModelText&&!_transparentFooterOwnsModel){
const usedModel=document.createElement('span');
usedModel.className='msg-used-model-inline';
usedModel.textContent=usedModelText;
fragments.push(usedModel);
}
if(window._showTokenUsage&&hasTurnUsage){
const usage=document.createElement('span');
usage.className='msg-usage-inline';

View file

@ -56,6 +56,9 @@ function getModelLabel(modelId) {{
}}
eval(extractFunc('_compactComposerModelChipLabel'));
eval(extractFunc('_usedModelTurnChipLabel'));
eval(extractFunc('_gatewayProviderName'));
eval(extractFunc('_gatewayRoutingLabel'));
eval(extractFunc('_formatGatewayModelLabel'));
const modelId = 'gpt-5-mini';
const expectedPresent = _compactComposerModelChipLabel(modelId, getModelLabel(modelId));
const cases = {{
@ -65,8 +68,13 @@ const cases = {{
_usedModel: modelId,
_gatewayRouting: {{ used_model: 'deepseek-v3.2' }},
}}),
suppressedRoutingOnly: _usedModelTurnChipLabel({{
_usedModel: modelId,
_gatewayRouting: {{ used_provider: 'openrouter' }},
}}),
absent: _usedModelTurnChipLabel({{}}),
nullMsg: _usedModelTurnChipLabel(null),
gatewayFallback: _formatGatewayModelLabel(modelId, getModelLabel(modelId), {{ used_provider: 'openrouter' }}),
}};
console.log(JSON.stringify(cases));
"""
@ -75,7 +83,10 @@ console.log(JSON.stringify(cases));
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
# The served model must be read from the agent AFTER the run — the agent
# mutates agent.model when a fallback fires, so the pre-run resolved_model
# would mis-attribute fallback turns.
assert "_used_model = getattr(agent, 'model', None) or resolved_model or model" in STREAMING_PY
assert "usage['used_model'] = _used_model" in STREAMING_PY
@ -106,18 +117,40 @@ def test_used_model_turn_chip_label_renders_and_suppresses_gateway_duplicate():
assert cases["present"] == cases["expectedPresent"]
assert cases["present"] # non-empty label when _usedModel is set
assert cases["suppressed"] == ""
# Routing metadata WITHOUT used_model must also suppress the additive chip:
# the gateway formatter owns the label (falling back to _usedModel), so a
# provider-only routing payload must not render two model labels.
assert cases["suppressedRoutingOnly"] == ""
assert cases["absent"] == ""
assert cases["nullMsg"] == ""
# When routing omits used_model, the gateway label falls back to the
# caller-provided model id (the settled footer passes msg._usedModel).
assert cases["gatewayFallback"].startswith(cases["expectedPresent"])
assert "via" in cases["gatewayFallback"]
def test_settled_footer_wires_used_model_chip_in_dom_paths():
assert "msg-used-model-inline" in UI_JS
assert "_usedModelTurnChipLabel(msg)" in UI_JS
# The settled footer must hand the served model to the gateway formatter as
# its fallback, and skip the generic chip when the transparent turn footer
# owns the model label (one model label per turn).
assert "_formatGatewayModelLabel(String(msg._usedModel||'').trim()||(S.session&&S.session.model)||'', '', routing)" in UI_JS
assert "_transparentFooterOwnsModel" in UI_JS
assert ".msg-used-model-inline" in STYLE_CSS
def test_settled_footer_orders_model_chip_after_duration():
# Match the transparent footer order (elapsed · model · …): the duration
# fragment must be pushed before the used-model fragment.
footer_block = UI_JS.split("const usedModelText=_usedModelTurnChipLabel(msg);", 1)[1]
duration_at = footer_block.index("duration.className='msg-duration-inline'")
used_model_at = footer_block.index("usedModel.className='msg-used-model-inline'")
assert duration_at < used_model_at
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
assert ".transparent-turn-footer .lf-model" in STYLE_CSS