feat(agent): route Claude/GPT model families to their native gateway wire (#3538)

## Summary

Databricks v2 chooses the gateway wire format — OpenAI Responses,
Anthropic Messages, or MLflow chat — purely from substrings in the
endpoint name. There is no family field on the endpoint to key off, so
the substring set *is* the routing contract. The matcher only recognised
`gpt-5`/`gpt5` and `claude`, which makes correct billing depend on every
Claude endpoint happening to be named with the literal string "claude".

## Why this matters

Getting a Claude model onto the Anthropic Messages route is exactly what
lets buzz attach the `cache_control` breakpoint (the fix in #3463). If a
Claude endpoint's catalog name omits "claude" — an alias, a bare
`opus-5`, a `goose-opus-5` — it silently falls through to the MLflow
(OpenAI-wire) path, where Anthropic prompt caching is **structurally
impossible**. The result is the same failure #3463 fixed: 0% cache
reads, the full ~10x read discount lost, and no error — a naming
convention quietly holding up a billing-correctness invariant.

## What changed

`databricks_v2_route_for_model` (`crates/buzz-agent/src/llm.rs`) now
matches broader, case-insensitive marker sets:

- **Claude → Anthropic Messages:** `claude`, `opus`, `sonnet`, `haiku`,
`mythos`, `fable` — the Claude family names and release code names, so a
Claude endpoint reaches the cache-capable route regardless of how it's
named.
- **GPT → OpenAI Responses:** the `gpt` family (now `gpt` on its own,
not just `gpt-5`) plus the GPT-5 launch code names `sol`, `luna`,
`terra`.

OpenAI markers are evaluated first, preserving the prior `gpt-5`-first
precedence for any name that could carry both. Names matching neither
set still fall through to the MLflow chat route.

## Testing

- `cargo fmt`, `cargo clippy -p buzz-agent --all-targets -- -D warnings`
— clean.
- `cargo test -p buzz-agent` — all green (299 lib + integration suites,
0 failures). The `databricks_v2_routes_by_model_family` test was
expanded to cover each new marker, the GPT-5 code names,
case-insensitivity, and the unchanged MLflow fallback (including
`gemini`).

## Relationship to #3463

#3463 taught the Anthropic path to request caching; this makes sure
Claude models actually land on that path. Follow-up still open:
surfacing `cache_creation_input_tokens` end-to-end so a persistent
`reads == 0 && writes == 0` reveals a disabled cache regardless of which
wire a model takes — happy to do that next.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Atish Patel <atish@squareup.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Atish Patel
2026-07-29 15:02:12 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 24d90d1280
commit 6438dedf83
+133 -8
View File
@@ -1019,13 +1019,53 @@ fn is_responses_required_error(body: &str) -> bool {
|| b.contains("use the responses api")
}
/// OpenAI-family code names that appear as their own segment in a Databricks v2
/// endpoint name (the GPT-5 launch aliases). The `gpt` family itself is matched
/// separately by segment prefix so `gpt`, `gpt5`, and the `gpt` of a split
/// `gpt-5` all qualify.
const DATABRICKS_V2_OPENAI_CODE_NAMES: &[&str] = &["sol", "luna", "terra"];
/// Anthropic (Claude) family and release code names that appear as their own
/// segment in a Databricks v2 endpoint name — the `claude` prefix, the family
/// names (`opus`, `sonnet`, `haiku`), and the release code names (`mythos`,
/// `fable`). Getting a Claude model onto the Anthropic Messages route is what
/// lets it carry a `cache_control` breakpoint; an endpoint that matches none of
/// these falls through to the MLflow (OpenAI-wire) path, where Anthropic prompt
/// caching is structurally impossible and the discount is silently lost.
const DATABRICKS_V2_CLAUDE_NAMES: &[&str] =
&["claude", "opus", "sonnet", "haiku", "mythos", "fable"];
/// Split a Databricks v2 endpoint name into its lowercase alphanumeric segments,
/// breaking on any non-alphanumeric delimiter (`-`, `_`, `.`, `/`, …). E.g.
/// `Databricks-Claude-Opus-5` -> `["databricks", "claude", "opus", "5"]`.
fn model_name_segments(model: &str) -> Vec<String> {
model
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|s| !s.is_empty())
.map(str::to_ascii_lowercase)
.collect()
}
fn databricks_v2_route_for_model(model: &str) -> DatabricksV2Route {
// Databricks v2 catalog names currently identify OpenAI-shaped GPT-5
// models and Anthropic-shaped Claude models by these substrings.
let lower = model.to_ascii_lowercase();
if lower.contains("gpt-5") || lower.contains("gpt5") {
// The v2 catalog exposes no family field, so the wire format is inferred
// from the endpoint name. Discovery deliberately keeps arbitrary custom
// aliases, so we match whole name *segments* rather than raw substrings: a
// substring test would misroute unrelated names — `consolidated-llama`
// (`sol`), `terraform-coder` (`terra`), `corpus-reranker`/`octopus-model`
// (`opus`) — onto a wire whose request shape their backend can't parse,
// turning a caching optimization into a hard request/parse failure. Segment
// matching still accepts real prefixed names like `goose-opus-5`.
let segments = model_name_segments(model);
let has_named_segment =
|names: &[&str]| segments.iter().any(|seg| names.contains(&seg.as_str()));
// `gpt` family: any segment beginning with `gpt` — covers `gpt`, `gpt5`, and
// the `gpt` segment of a split `gpt-5`, without matching mid-word.
let is_gpt_family = segments.iter().any(|seg| seg.starts_with("gpt"));
// OpenAI is checked before Claude so a name carrying both markers resolves
// to the OpenAI wire (preserving the prior `gpt-5`-first precedence).
if is_gpt_family || has_named_segment(DATABRICKS_V2_OPENAI_CODE_NAMES) {
DatabricksV2Route::OpenAiResponses
} else if lower.contains("claude") {
} else if has_named_segment(DATABRICKS_V2_CLAUDE_NAMES) {
DatabricksV2Route::AnthropicMessages
} else {
DatabricksV2Route::MlflowChatCompletions
@@ -2579,20 +2619,105 @@ mod tests {
#[test]
fn databricks_v2_routes_by_model_family() {
use DatabricksV2Route::{AnthropicMessages, MlflowChatCompletions, OpenAiResponses};
for (model, route, path) in [
// OpenAI-shaped: the gpt family plus the GPT-5 code names.
(
"databricks-gpt-5-5",
DatabricksV2Route::OpenAiResponses,
OpenAiResponses,
"/ai-gateway/openai/v1/responses",
),
("gpt-4o", OpenAiResponses, "/ai-gateway/openai/v1/responses"),
// The intentional dashless `gpt5` spelling still routes to OpenAI.
("gpt5", OpenAiResponses, "/ai-gateway/openai/v1/responses"),
(
"databricks-gpt-5-6-luna",
OpenAiResponses,
"/ai-gateway/openai/v1/responses",
),
(
"databricks-gpt-5-6-sol",
OpenAiResponses,
"/ai-gateway/openai/v1/responses",
),
(
"databricks-terra",
OpenAiResponses,
"/ai-gateway/openai/v1/responses",
),
// Anthropic-shaped: the claude prefix, the family names, and the
// release code names — each must reach the cache-capable route even
// when the endpoint name omits the literal "claude".
(
"databricks-claude-opus-4-7",
DatabricksV2Route::AnthropicMessages,
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
(
"goose-opus-5",
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
(
"databricks-sonnet-5",
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
(
"databricks-haiku-4-5",
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
(
"databricks-mythos-5",
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
(
"databricks-fable-5",
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
// Case-insensitive.
(
"Databricks-Claude-Opus-5",
AnthropicMessages,
"/ai-gateway/anthropic/v1/messages",
),
// Unrecognised names still fall through to the MLflow chat route.
(
"custom-tool-model",
DatabricksV2Route::MlflowChatCompletions,
MlflowChatCompletions,
"/ai-gateway/mlflow/v1/chat/completions",
),
(
"databricks-gemini-3-pro",
MlflowChatCompletions,
"/ai-gateway/mlflow/v1/chat/completions",
),
// Collision guard: short code names must match only as whole
// segments, never as substrings of an unrelated custom alias.
// Each of these embeds a marker (`sol`, `terra`, `opus`) mid-word
// and must stay on the MLflow fallback, not adopt a wire its
// backend can't parse.
(
"consolidated-llama",
MlflowChatCompletions,
"/ai-gateway/mlflow/v1/chat/completions",
),
(
"terraform-coder",
MlflowChatCompletions,
"/ai-gateway/mlflow/v1/chat/completions",
),
(
"corpus-reranker",
MlflowChatCompletions,
"/ai-gateway/mlflow/v1/chat/completions",
),
(
"octopus-model",
MlflowChatCompletions,
"/ai-gateway/mlflow/v1/chat/completions",
),
] {