diff --git a/api/streaming.py b/api/streaming.py index c5329ae3..8f9b1314 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -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. diff --git a/static/ui.js b/static/ui.js index ff7a75ab..6320392e 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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'; diff --git a/tests/test_issue6068_used_model_footer.py b/tests/test_issue6068_used_model_footer.py index 09f553bf..6a32ac25 100644 --- a/tests/test_issue6068_used_model_footer.py +++ b/tests/test_issue6068_used_model_footer.py @@ -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 \ No newline at end of file + assert ".transparent-turn-footer .lf-model" in STYLE_CSS