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:
parent
1598940091
commit
9bf876598d
3 changed files with 60 additions and 13 deletions
|
|
@ -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.
|
||||
|
|
|
|||
29
static/ui.js
29
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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue