diff --git a/studio/backend/core/inference/external_provider.py b/studio/backend/core/inference/external_provider.py index c112f6e096..6b89573dfd 100644 --- a/studio/backend/core/inference/external_provider.py +++ b/studio/backend/core/inference/external_provider.py @@ -191,7 +191,15 @@ def _anthropic_supports_compaction(model: str) -> bool: def _anthropic_supports_fast_mode(model: str) -> bool: - return model.startswith(_ANTHROPIC_FAST_MODE_PREFIXES) + # Require the prefix to terminate at a family boundary (end of + # string or a "-" separator before the date snapshot) so the + # check does not light up on hypothetical IDs like + # "claude-opus-4-70" / "claude-opus-4-7b" that merely share a + # prefix with the supported families. + return any( + model == p or model.startswith(f"{p}-") + for p in _ANTHROPIC_FAST_MODE_PREFIXES + ) class _MistralThinkingSpec(NamedTuple): @@ -2466,20 +2474,27 @@ class ExternalProviderClient: "Anthropic refusal stop_reason (model=%s)", model, ) - # Trailing HTML comment is a stable - # sentinel the chat-adapter matches - # to drop this assistant turn from - # the next request's outbound - # history. Anthropic's docs say - # the refused turn must be removed - # or updated before continuing or - # the next call will keep refusing. + # User-facing notice so the chat bubble + # is not silently empty after the + # safety classifier truncates the + # response. The out-of-band drop + # signal rides a separate _toolEvent + # below so assistant text can never + # spoof a context reset by including + # a literal marker. Anthropic's docs + # say the refused turn must be + # removed or updated before + # continuing or the next call will + # keep refusing. + # https://platform.claude.com/docs/en/test-and-evaluate/strengthen-guardrails/handle-streaming-refusals yield _content_chunk( "\n\n_The response was stopped by " "Anthropic's safety classifier. Edit " "or remove the previous turn and try " "again._" - "\n" + ) + yield _emit_tool_event( + {"type": "anthropic_refusal"} ) if mapped is not None: chunk = { @@ -4010,6 +4025,15 @@ def _build_usage_chunk( "cache_creation_input_tokens": cache_creation, "cache_read_input_tokens": cache_read, } + # Anthropic fast-mode responses include `usage.speed` so callers + # can verify whether a premium fast-mode request actually ran + # fast (the API silently falls back to "standard" when the + # beta is unsupported or rate-limited). Surface it on the + # OpenAI-style chunk so the pricing/cost ledger can apply the + # 6x multiplier without re-derivation. + speed = last_usage.get("speed") + if speed in ("fast", "standard"): + usage_block["speed"] = speed else: prompt_tokens = last_usage.get("input_tokens") or 0 cached = 0 diff --git a/studio/backend/core/inference/pricing.py b/studio/backend/core/inference/pricing.py index 74c57fa594..d88001e47c 100644 --- a/studio/backend/core/inference/pricing.py +++ b/studio/backend/core/inference/pricing.py @@ -105,6 +105,10 @@ OPENAI_PRICING: dict[str, dict[str, float]] = { ANTHROPIC_CACHE_5M_WRITE_MULT = 1.25 ANTHROPIC_CACHE_1H_WRITE_MULT = 2.0 ANTHROPIC_CACHE_READ_MULT = 0.1 +# Anthropic fast-mode beta (Opus 4.6 / 4.7 only): 6x standard rates on +# both input and output across the full context window. +# https://platform.claude.com/docs/en/build-with-claude/fast-mode#pricing +ANTHROPIC_FAST_MODE_MULT = 6.0 # OpenAI: cache reads are 0.1x base input, cache writes are not billed # separately (the first prefix-write request just pays normal input). @@ -235,6 +239,17 @@ def calculate_cost( base = prices["input_per_mtok"] out_per = prices["output_per_mtok"] + # Anthropic fast-mode beta: 6x standard on both input + output across + # the full context window. Prompt-cache multipliers stack on top of + # the fast-mode base per the docs, so applying the multiplier once + # to (base, out_per) propagates correctly into the cache_*_usd + # buckets computed below. + if provider == "anthropic" and usage.get("speed") == "fast": + base *= ANTHROPIC_FAST_MODE_MULT + out_per *= ANTHROPIC_FAST_MODE_MULT + if out["model_priced"]: + out["model_priced"] = f"{out['model_priced']} (fast)" + out["input_usd"] = (input_tokens / 1_000_000.0) * base out["output_usd"] = (output_tokens / 1_000_000.0) * out_per @@ -315,6 +330,7 @@ def pricing_snapshot() -> dict[str, Any]: "cache_5m_write_mult": ANTHROPIC_CACHE_5M_WRITE_MULT, "cache_1h_write_mult": ANTHROPIC_CACHE_1H_WRITE_MULT, "cache_read_mult": ANTHROPIC_CACHE_READ_MULT, + "fast_mode_mult": ANTHROPIC_FAST_MODE_MULT, "web_search_usd_per_1k": ANTHROPIC_WEB_SEARCH_USD_PER_1K, "code_execution_usd_per_hour": ANTHROPIC_CODE_EXEC_USD_PER_HOUR, }, diff --git a/studio/backend/tests/test_anthropic_fast_mode_and_refusal.py b/studio/backend/tests/test_anthropic_fast_mode_and_refusal.py index ec03264ad1..3037e8258e 100644 --- a/studio/backend/tests/test_anthropic_fast_mode_and_refusal.py +++ b/studio/backend/tests/test_anthropic_fast_mode_and_refusal.py @@ -160,14 +160,20 @@ def test_refusal_emits_user_facing_notice_and_content_filter_finish(monkeypatch) assert "Hello." in body, body -def test_refusal_emits_hidden_sentinel_for_chat_adapter_drop(monkeypatch): - """Refused turns embed a hidden sentinel the chat-adapter matches on. +def test_refusal_emits_tool_event_for_chat_adapter_drop(monkeypatch): + """Refused turns emit an out-of-band `_toolEvent` the chat-adapter + latches into assistant `metadata.custom.anthropicRefusal`. - The frontend drops any assistant message containing this sentinel + The frontend drops any assistant message with that metadata flag from the next request's outbound history, per Anthropic's docs: leaving the refused output in context causes the next call to keep - refusing. + refusing. Using a tool event (not a text sentinel) means assistant + content can never spoof a context reset. """ _, lines = _capture(monkeypatch, sse = _refusal_sse()) body = "\n".join(lines) - assert "studio:anthropic-refusal" in body, body + assert '"_toolEvent": {"type": "anthropic_refusal"}' in body, body + # The visible refusal notice must NOT carry a text sentinel: an + # assistant message that echoes that string would otherwise spoof + # a context reset on the next request. + assert "studio:anthropic-refusal" not in body, body diff --git a/studio/backend/tests/test_anthropic_fast_mode_edge.py b/studio/backend/tests/test_anthropic_fast_mode_edge.py index 1025395d08..723d24a5d4 100644 --- a/studio/backend/tests/test_anthropic_fast_mode_edge.py +++ b/studio/backend/tests/test_anthropic_fast_mode_edge.py @@ -23,14 +23,16 @@ This file fills in the remaining behaviour cliffs: duplicates and no truncation. * Idempotence: setting ``fast_mode=True`` twice via the same body still results in one beta-header entry. -* Streaming refusal sentinel: emitted exactly once, with the exact - ```` token the frontend matches on, - and the notice always precedes the finish_reason chunk so a UI +* Streaming refusal signal: a single out-of-band ``_toolEvent`` carrying + ``{"type": "anthropic_refusal"}`` rides alongside the visible refusal + notice. The frontend latches the tool event into assistant + metadata.custom.anthropicRefusal; assistant text never controls the + pruner. The notice always precedes the finish_reason chunk so a UI reading the SSE in order paints text before flipping to ``content_filter``. * Refusal on a non-Opus model: refusal handling is provider-side, not model-gated, so a refusal mid-stream on Sonnet must still surface the - notice + sentinel. + notice + tool event. * Non-destruction: when ``fast_mode`` is ``None``, the outbound body and headers must be byte-identical to the version that omits the argument entirely. This guarantees the upgrade path is non-breaking @@ -328,30 +330,41 @@ def test_refusal_notice_appears_before_content_filter_chunk(monkeypatch): assert notice_idx < filter_idx, (notice_idx, filter_idx, lines) -def test_refusal_sentinel_emitted_exactly_once(monkeypatch): - """A single refusal must emit the chat-adapter drop sentinel one time. +def test_refusal_tool_event_emitted_exactly_once(monkeypatch): + """A single refusal must emit the chat-adapter drop signal one time. - The frontend's ``toOpenAIMessage`` uses ``includes`` for the match, - so duplicates wouldn't cause false drops -- but emitting it twice - would still inflate the bubble and break the UX guarantee that the - sentinel is an invisible HTML comment. + The frontend latches the tool event into assistant metadata and + uses it to drop the refused pair from the next request. Emitting + twice would still be metadata-idempotent but indicates a backend + bug, so pin the count. """ _, lines = _capture(monkeypatch, sse = _refusal_sse()) body = "\n".join(lines) - count = body.count("") + count = body.count('"_toolEvent": {"type": "anthropic_refusal"}') assert count == 1, (count, body) +def test_refusal_text_carries_no_html_sentinel(monkeypatch): + """Belt-and-braces: ensure the assistant-visible refusal text does + not embed any ``studio:anthropic-refusal`` marker. The drop signal + must ride the out-of-band ``_toolEvent`` channel only -- otherwise + an assistant message that echoes the literal marker would spoof a + context reset on the next request.""" + _, lines = _capture(monkeypatch, sse = _refusal_sse()) + body = "\n".join(lines) + assert "studio:anthropic-refusal" not in body, body + + def test_refusal_handling_works_on_sonnet_model(monkeypatch): """Refusal handling is provider-side, not gated on a fast-mode-capable model. If Anthropic's classifier refuses a Sonnet stream it must - surface the same notice + sentinel + content_filter mapping.""" + surface the same notice + tool event + content_filter mapping.""" _, lines = _capture( monkeypatch, sse = _refusal_sse("claude-sonnet-4-6"), model = "claude-sonnet-4-6" ) body = "\n".join(lines) assert "stopped by Anthropic's safety classifier" in body, body - assert "" in body, body + assert '"_toolEvent": {"type": "anthropic_refusal"}' in body, body assert '"finish_reason": "content_filter"' in body, body @@ -369,7 +382,8 @@ def test_refusal_preserves_partial_assistant_text(monkeypatch): def test_refusal_chunk_is_proper_openai_delta_shape(monkeypatch): """The notice rides a normal ``choices[0].delta.content`` chunk, not a finish_reason chunk, so OpenAI-spec clients (Aurora, OpenAI SDK) - treat it as ordinary streamed text.""" + treat it as ordinary streamed text. The drop signal arrives on a + separate `_toolEvent` chunk (verified below).""" _, lines = _capture(monkeypatch, sse = _refusal_sse(), model = "claude-opus-4-7") # Find the chunk that carries the refusal text. notice_chunk = None @@ -383,7 +397,27 @@ def test_refusal_chunk_is_proper_openai_delta_shape(monkeypatch): # Must NOT carry a finish_reason itself -- that comes on the next # chunk. assert choice.get("finish_reason") in (None,), notice_chunk - assert "" in choice["delta"]["content"] + # Refusal text is plain-spoken; no embedded sentinel. + assert "studio:anthropic-refusal" not in choice["delta"]["content"] + + +def test_refusal_tool_event_chunk_shape(monkeypatch): + """The out-of-band drop signal rides a separate chunk shaped like a + Studio `_toolEvent` envelope (choices=[{index:0, delta:{}, + finish_reason:null}] + `_toolEvent`). The frontend latches on + `_toolEvent.type == "anthropic_refusal"` and stamps the assistant + message metadata; assistant text never controls the prune.""" + _, lines = _capture(monkeypatch, sse = _refusal_sse(), model = "claude-opus-4-7") + refusal_chunk = None + for line in lines: + if line.startswith("data: ") and "anthropic_refusal" in line: + refusal_chunk = json.loads(line[len("data: ") :]) + break + assert refusal_chunk is not None, lines + assert refusal_chunk["_toolEvent"] == {"type": "anthropic_refusal"}, refusal_chunk + choice = refusal_chunk["choices"][0] + assert choice["delta"] == {}, refusal_chunk + assert choice["finish_reason"] is None, refusal_chunk # ──────────────────────────── future-proofing ──────────────────────────── @@ -415,3 +449,85 @@ def test_fast_mode_dropped_on_opus_4_5_dated_snapshot(monkeypatch): cap, _ = _capture(monkeypatch, fast_mode = True, model = "claude-opus-4-5-2025-08-01") assert "speed" not in cap["body"], cap["body"] assert "fast-mode-2026-02-01" not in cap["headers"].get("anthropic-beta", "") + + +def test_fast_mode_rejects_prefix_collision_4_70(monkeypatch): + """The family gate must require a "-" boundary after the supported + prefix so hypothetical IDs like ``claude-opus-4-70`` or + ``claude-opus-4-7b`` do not get fast-mode on a naive + ``startswith`` match.""" + cap, _ = _capture(monkeypatch, fast_mode = True, model = "claude-opus-4-70") + assert "speed" not in cap["body"], cap["body"] + assert "fast-mode-2026-02-01" not in cap["headers"].get("anthropic-beta", "") + + +def test_fast_mode_rejects_prefix_collision_4_7b(monkeypatch): + cap, _ = _capture(monkeypatch, fast_mode = True, model = "claude-opus-4-7b") + assert "speed" not in cap["body"], cap["body"] + assert "fast-mode-2026-02-01" not in cap["headers"].get("anthropic-beta", "") + + +def test_fast_mode_rejects_prefix_collision_4_6_extra(monkeypatch): + cap, _ = _capture(monkeypatch, fast_mode = True, model = "claude-opus-4-60") + assert "speed" not in cap["body"], cap["body"] + assert "fast-mode-2026-02-01" not in cap["headers"].get("anthropic-beta", "") + + +# ──────────────────────────── usage.speed propagation ──────────────────────────── +def _fast_speed_sse(model: str = "claude-opus-4-7", speed: str = "fast") -> bytes: + return ( + b'event: message_start\ndata: {"type":"message_start","message":' + b'{"id":"m1","content":[],"model":"' + model.encode() + b'",' + b'"role":"assistant","stop_reason":null,"usage":' + b'{"input_tokens":4,"output_tokens":1}}}\n\n' + b'event: content_block_start\ndata: {"type":"content_block_start",' + b'"index":0,"content_block":{"type":"text","text":""}}\n\n' + b'event: content_block_delta\ndata: {"type":"content_block_delta",' + b'"index":0,"delta":{"type":"text_delta","text":"hi"}}\n\n' + b'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n' + b'event: message_delta\ndata: {"type":"message_delta",' + b'"delta":{"stop_reason":"end_turn"},' + b'"usage":{"output_tokens":5,"speed":"' + speed.encode() + b'"}}\n\n' + b'event: message_stop\ndata: {"type":"message_stop"}\n\n' + ) + + +def test_usage_speed_propagates_to_final_usage_chunk_fast(monkeypatch): + """When Anthropic returns ``usage.speed == "fast"`` the Studio + OpenAI-style usage chunk must carry that field so the cost ledger + can apply the 6x multiplier and clients can verify a fast-mode + request actually ran fast.""" + _, lines = _capture(monkeypatch, sse = _fast_speed_sse(speed = "fast")) + usage_lines = [ + l for l in lines if l.startswith("data: ") and '"usage"' in l + ] + assert usage_lines, lines + parsed = [json.loads(l[len("data: ") :]) for l in usage_lines] + speeds = [p["usage"].get("speed") for p in parsed if "usage" in p] + assert "fast" in speeds, parsed + + +def test_usage_speed_propagates_to_final_usage_chunk_standard(monkeypatch): + _, lines = _capture(monkeypatch, sse = _fast_speed_sse(speed = "standard")) + parsed = [ + json.loads(l[len("data: ") :]) + for l in lines + if l.startswith("data: ") and '"usage"' in l + ] + speeds = [p["usage"].get("speed") for p in parsed if "usage" in p] + assert "standard" in speeds, parsed + + +def test_usage_speed_absent_when_anthropic_does_not_report(monkeypatch): + """When the upstream stream omits ``usage.speed`` (pre-fast-mode + models / older snapshots), the Studio usage chunk must not invent + a value.""" + _, lines = _capture(monkeypatch) + parsed = [ + json.loads(l[len("data: ") :]) + for l in lines + if l.startswith("data: ") and '"usage"' in l + ] + for p in parsed: + usage = p.get("usage") or {} + assert "speed" not in usage, p diff --git a/studio/backend/tests/test_pricing.py b/studio/backend/tests/test_pricing.py index cc8c16993c..78eda451db 100644 --- a/studio/backend/tests/test_pricing.py +++ b/studio/backend/tests/test_pricing.py @@ -14,6 +14,7 @@ from core.inference.pricing import ( ANTHROPIC_CACHE_5M_WRITE_MULT, ANTHROPIC_CACHE_1H_WRITE_MULT, ANTHROPIC_CACHE_READ_MULT, + ANTHROPIC_FAST_MODE_MULT, ANTHROPIC_PRICING, OPENAI_CACHE_READ_MULT, OPENAI_CONTAINER_USD_PER_HOUR, @@ -57,6 +58,65 @@ def test_anthropic_opus_4_7_input_and_output_math(): assert _isclose(out["total_usd"], 30.0) +# ── Anthropic fast-mode 6x multiplier (Opus 4.6 / 4.7 only) ───────── + + +def test_anthropic_fast_mode_charges_6x_standard_opus(): + """Per https://platform.claude.com/docs/en/build-with-claude/fast-mode + fast-mode requests are billed at 6x the standard Opus rates across + the full context window. ``usage.speed == "fast"`` is the trigger.""" + out = calculate_cost( + "anthropic", + "claude-opus-4-7", + { + "input_tokens": 1_000_000, + "output_tokens": 1_000_000, + "speed": "fast", + }, + ) + assert _isclose(out["input_usd"], 5.0 * ANTHROPIC_FAST_MODE_MULT) + assert _isclose(out["output_usd"], 25.0 * ANTHROPIC_FAST_MODE_MULT) + assert _isclose(out["total_usd"], 30.0 * ANTHROPIC_FAST_MODE_MULT) + assert "(fast)" in out["model_priced"], out["model_priced"] + + +def test_anthropic_fast_mode_does_not_affect_standard_speed(): + """``speed: "standard"`` (or missing) keeps the base rates.""" + out_standard = calculate_cost( + "anthropic", + "claude-opus-4-7", + { + "input_tokens": 1_000_000, + "output_tokens": 1_000_000, + "speed": "standard", + }, + ) + out_missing = calculate_cost( + "anthropic", + "claude-opus-4-7", + {"input_tokens": 1_000_000, "output_tokens": 1_000_000}, + ) + assert _isclose(out_standard["total_usd"], out_missing["total_usd"]) + assert _isclose(out_standard["total_usd"], 30.0) + + +def test_anthropic_fast_mode_stacks_with_cache_read_multiplier(): + """Cache multipliers apply on top of fast-mode (per docs).""" + base = ANTHROPIC_PRICING["claude-opus-4-7"]["input_per_mtok"] + out = calculate_cost( + "anthropic", + "claude-opus-4-7", + { + "input_tokens": 0, + "output_tokens": 0, + "cache_read_input_tokens": 1_000_000, + "speed": "fast", + }, + ) + expected = base * ANTHROPIC_FAST_MODE_MULT * ANTHROPIC_CACHE_READ_MULT + assert _isclose(out["cache_read_usd"], expected) + + # ── Anthropic cache write 5m + read multipliers ────────────────────── @@ -412,6 +472,7 @@ def test_snapshot_contains_provider_buckets_and_multipliers(): assert a["cache_5m_write_mult"] == ANTHROPIC_CACHE_5M_WRITE_MULT assert a["cache_1h_write_mult"] == ANTHROPIC_CACHE_1H_WRITE_MULT assert a["cache_read_mult"] == ANTHROPIC_CACHE_READ_MULT + assert a["fast_mode_mult"] == ANTHROPIC_FAST_MODE_MULT assert "web_search_usd_per_1k" in a assert "code_execution_usd_per_hour" in a assert "models" in o and "gpt-5.5" in o["models"] diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 73411450d8..07b346d9f0 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -341,12 +341,19 @@ function collectImageParts( return parts; } -// Sentinel emitted by the backend at the end of an assistant turn that -// Anthropic ended with stop_reason="refusal". We drop the entire turn -// from the next request body because Anthropic's guidance says leaving -// the refused output in context causes the next call to keep refusing. -// Keep in sync with studio/backend/core/inference/external_provider.py. -const ANTHROPIC_REFUSAL_SENTINEL = ""; +// Out-of-band refusal flag stamped onto assistant message metadata by the +// adapter when the backend emits an `anthropic_refusal` _toolEvent. We +// drop the entire turn from the next request body because Anthropic's +// guidance says leaving the refused output in context causes the next +// call to keep refusing. Using metadata (not text) means assistant +// content can never spoof a context reset. +function isAnthropicRefusalMessage(message: RunMessage): boolean { + if (message.role !== "assistant") return false; + const metadata = (message as { metadata?: unknown }).metadata as + | { custom?: Record } + | undefined; + return metadata?.custom?.anthropicRefusal === true; +} function toOpenAIMessage(message: RunMessage): { role: "system" | "user" | "assistant"; @@ -368,7 +375,7 @@ function toOpenAIMessage(message: RunMessage): { /data:audio\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, "[audio]", ); - if (textContent.includes(ANTHROPIC_REFUSAL_SENTINEL)) { + if (isAnthropicRefusalMessage(message)) { // Drop refused assistant turns entirely so the next request does // not re-trigger the same safety classifier. The user-visible // notice stays in the rendered transcript; only the outbound @@ -938,14 +945,12 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { // guidance is explicit that leaving the offending user prompt in // context causes the next request to re-trigger the classifier; // returning null on just the assistant side was not enough. + // The refusal flag rides assistant metadata.custom.anthropicRefusal + // (set out-of-band from the backend _toolEvent) so assistant text + // can never spoof a context reset. const survivingMessages: RunMessage[] = []; for (const message of messages) { - if ( - message.role === "assistant" && - collectTextParts(message) - .join("\n") - .includes(ANTHROPIC_REFUSAL_SENTINEL) - ) { + if (isAnthropicRefusalMessage(message)) { const last = survivingMessages.at(-1); if (last && last.role === "user") { survivingMessages.pop(); @@ -1025,8 +1030,12 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { }); } } - const imageBase64 = findLatestUserImageBase64(messages); - const audioBase64 = findLatestUserAudioBase64(messages); + // Scan the post-prune history so a refused user turn with an + // image/audio attachment does not gate / mis-attribute the next + // non-refused turn (the refused pair is pruned from the request + // body above). + const imageBase64 = findLatestUserImageBase64(survivingMessages); + const audioBase64 = findLatestUserAudioBase64(survivingMessages); // Block when ANY image is in the outbound payload (current or // prior turns) and the loaded model can't process images. Keeps @@ -1062,7 +1071,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { if (audioBase64) { const audioName = runtime.pendingAudioName; if (audioName) { - const lastUserMsg = [...messages] + const lastUserMsg = [...survivingMessages] .reverse() .find((m) => m.role === "user"); if (lastUserMsg) sentAudioNames.set(lastUserMsg.id, audioName); @@ -1173,6 +1182,12 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { // Tool call content parts — accumulated and yielded cumulatively. // result is set directly on the tool-call part when tool_end arrives. const toolCallParts: ToolCallMessagePart[] = []; + // Latched when the backend emits an `anthropic_refusal` tool event + // on Anthropic stop_reason="refusal". Stamped onto the final + // assistant message metadata as `custom.anthropicRefusal` so the + // history-prune logic above can drop the refused pair on the next + // request without relying on text-content sentinels. + let anthropicRefusalSeen = false; let serverMetadata: { usage?: ServerUsage; timings?: ServerTimings; @@ -1645,6 +1660,14 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { } continue; } + if (toolEvent.type === "anthropic_refusal") { + // Backend signalled stop_reason="refusal" out-of-band. + // Latch and stamp onto the final message metadata so + // the two-pass history pruner can drop the refused + // pair on the next request. + anthropicRefusalSeen = true; + continue; + } if (toolEvent.type === "tool_start") { const id = (toolEvent.tool_call_id as string) || @@ -1965,6 +1988,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { timing: finalTiming, custom: { reasoningDuration, + // Persisted refusal flag; drives the two-pass history + // pruner that drops the refused assistant + user pair + // on the next request. + anthropicRefusal: anthropicRefusalSeen || undefined, serverTimings: meta?.timings ?? undefined, contextUsage: meta?.usage ? { diff --git a/studio/frontend/src/features/chat/provider-capabilities.ts b/studio/frontend/src/features/chat/provider-capabilities.ts index ba96a0d677..0f5e487a6a 100644 --- a/studio/frontend/src/features/chat/provider-capabilities.ts +++ b/studio/frontend/src/features/chat/provider-capabilities.ts @@ -155,8 +155,12 @@ export function providerSupportsFastMode( ): boolean { if (providerType !== "anthropic") return false; if (!modelId) return false; - return ANTHROPIC_FAST_MODE_MODEL_PREFIXES.some((prefix) => - modelId.startsWith(prefix), + // Require the prefix to terminate at a family boundary (end of + // string or "-" before a dated snapshot) so the check does not + // match unsupported IDs that merely share a prefix, e.g. + // "claude-opus-4-70" / "claude-opus-4-7b". + return ANTHROPIC_FAST_MODE_MODEL_PREFIXES.some( + (prefix) => modelId === prefix || modelId.startsWith(`${prefix}-`), ); }