mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(model-capabilities): drive model capabilities and labels from one manifest (#5597)
## Summary Centralizes model capability knowledge — thinking mode, supported effort levels, wire routes, and human-readable labels — into a single manifest, `scripts/model-capabilities.json`. Rust and TypeScript each get a small interpreter that reads the same manifest, replacing hand-maintained tables scattered across both languages that had already drifted apart. A capability change is now a data edit, not parallel edits to two code paths. Supersedes the codegen approach explored in #3603. A cross-language contract keeps the two interpreters honest: `scripts/normative-corpus.json` is a golden snapshot generated from the Rust resolver (103 vectors covering all six capability axes) and replayed natively in TS. CI fails if either language disagrees with the corpus or the corpus drifts from the resolver. Regenerate with `just regen-model-corpus`. ## Behavior changes - **Effort dropdown for `openai-compat` providers** no longer offers `max`. The request path always clamped `max` to `xhigh` on the wire, so the UI stops offering a value that was silently rewritten. UI-only, wire-identical. - **Databricks v2 routing (wire-visible):** uncurated endpoint names carrying a bare Claude code-name segment (e.g. `goose-opus-5`) now route to the MLflow chat wire instead of Anthropic Messages — they lose Anthropic prompt caching but still succeed on a valid OpenAI-compatible wire. Curated `databricks-claude-*` records and any name starting with `claude` are unchanged. A handful of other uncurated/adversarial name shapes similarly fall back to MLflow chat instead of pattern-matched routes; every curated model resolves identically to before, all axes. - **Curated model labels on the real discovery path.** The Databricks API returns no display name, so discovery emits the raw endpoint id as the model `name` (`{id, name: id}`) on every path. `ModelEntry.name` is now curated at all four construction seams in `buzz-agent` — v2 discovery, v1 parse, the auth-empty default catalog, and the configured-model fallback — via a read-only `databricks_registry_label` lookup over the manifest's `databricks_v2` exact records; `id` stays the raw wire/config value. A known id renders its curated label (`databricks-gpt-5-5` → `GPT-5.5`), an unknown id passes through unchanged, and the default-catalog row reads `GPT-5.5 (default catalog)`. As a defense against older `buzz-agent` binaries and any harness that echoes ids, `resolveModelLabel` treats a discovered name equal to the trimmed id as absent and falls through to the registry tier; a genuinely distinct name (including the suffixed default-catalog label) still wins. ## Cleanup Deletes the duplicated capability tables and their tests: the `config.rs` gpt5 matchers, effort tables, and clamp logic; the legacy segment-based Databricks v2 route classifier in `llm.rs`; and the TS hand tables plus `effortTable.fixture.json`. All are replaced by manifest lookups through the shared resolver — no line of capability data exists in two places. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -46,10 +46,14 @@ jobs:
|
||||
- 'deny.toml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- 'scripts/run-tests.sh'
|
||||
- 'scripts/model-capabilities.json'
|
||||
- 'scripts/normative-corpus.json'
|
||||
- 'justfile'
|
||||
desktop:
|
||||
- 'scripts/check-file-sizes-core.mjs'
|
||||
- 'scripts/check-file-sizes-core.test.mjs'
|
||||
- 'scripts/model-capabilities.json'
|
||||
- 'scripts/normative-corpus.json'
|
||||
- 'desktop/**'
|
||||
- '!desktop/src-tauri/**'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
17
Justfile
17
Justfile
@@ -323,6 +323,14 @@ test-unit:
|
||||
# because nothing in CI runs `cargo test --workspace` — workspace
|
||||
# membership alone buys clippy/check, not a single executed test.
|
||||
cargo nextest run -p buzz-backend-kubernetes
|
||||
# buzz-agent model-capabilities corpus: the Rust half of the
|
||||
# cross-language drift guard. `model_capabilities.rs` embeds
|
||||
# scripts/model-capabilities.json + scripts/normative-corpus.json via
|
||||
# include_str! and replays all 103 vectors as pure in-process tests (no
|
||||
# infra). Enumerated explicitly because nothing in CI runs
|
||||
# `cargo test --workspace`; without this step a manifest edit that
|
||||
# diverges Rust from the corpus ships green.
|
||||
cargo nextest run -p buzz-agent --lib
|
||||
else
|
||||
./scripts/run-tests.sh unit
|
||||
fi
|
||||
@@ -331,6 +339,15 @@ test-unit:
|
||||
test-integration:
|
||||
./scripts/run-tests.sh integration
|
||||
|
||||
# Regenerate the model-capability normative corpus from the production Rust
|
||||
# resolver. The corpus is a golden snapshot, never hand-edited: this runs the
|
||||
# `#[ignore]`d writer test in buzz-agent, which serializes `resolve()` over the
|
||||
# inputs-only question table to scripts/normative-corpus.json. Run this after
|
||||
# any model-capabilities.json edit, then commit the regenerated file. The
|
||||
# `corpus_matches_generated_snapshot` gate fails CI if the committed file drifts.
|
||||
regen-model-corpus:
|
||||
cargo test -p buzz-agent --lib model_capabilities::tests::regen_corpus_file -- --ignored --exact
|
||||
|
||||
# Buzz shared compute e2e: current desktop discovery/admission logic and
|
||||
# Playwright UI coverage.
|
||||
mesh-e2e:
|
||||
|
||||
@@ -23,28 +23,42 @@ use crate::{
|
||||
types::AgentError,
|
||||
};
|
||||
|
||||
/// A discovered model entry: `id` is the picker value, `name` is the display
|
||||
/// label (same as `id` for Databricks — the API has no separate display name).
|
||||
/// A discovered model entry: `id` is the picker value (the raw endpoint id, and
|
||||
/// the wire/config value), `name` is the display label. The Databricks API has
|
||||
/// no display-name field, so discovery curates `name` from the capability
|
||||
/// manifest ([`model_capabilities::databricks_registry_label`]) — a known id
|
||||
/// yields its curated label (e.g. `GPT-5.5`), an unknown id falls back to the
|
||||
/// raw id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModelEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Known Databricks AI Gateway v2 models — used only when an authenticated
|
||||
/// `api/ai-gateway/v2/endpoints` call succeeds with an empty list.
|
||||
/// Mirrors goose's `DATABRICKS_V2_KNOWN_MODELS`.
|
||||
pub const DATABRICKS_V2_KNOWN_MODELS: &[&str] =
|
||||
&["databricks-gpt-5-5", "databricks-claude-opus-4-7"];
|
||||
|
||||
const AUTHENTICATED_EMPTY_CATALOG_SUFFIX: &str = " (default catalog)";
|
||||
|
||||
/// Curated display label for a discovered Databricks endpoint id: the manifest's
|
||||
/// exact-record label when one exists, otherwise the raw id. The API returns no
|
||||
/// display name, so this is the single seam that turns a raw endpoint id into a
|
||||
/// human label for the picker.
|
||||
fn curated_model_name(id: &str) -> String {
|
||||
crate::model_capabilities::databricks_registry_label(id)
|
||||
.unwrap_or(id)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Fallback catalog used only when an authenticated `api/ai-gateway/v2/endpoints`
|
||||
/// call succeeds with an empty list. The known-model ids come from the manifest
|
||||
/// ([`model_capabilities::databricks_v2_known_models`]), the single runtime source.
|
||||
fn authenticated_empty_v2_catalog() -> Vec<ModelEntry> {
|
||||
DATABRICKS_V2_KNOWN_MODELS
|
||||
crate::model_capabilities::databricks_v2_known_models()
|
||||
.iter()
|
||||
.map(|id| ModelEntry {
|
||||
id: id.to_string(),
|
||||
name: format!("{id}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}"),
|
||||
id: id.clone(),
|
||||
name: format!(
|
||||
"{}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}",
|
||||
curated_model_name(id)
|
||||
),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -205,8 +219,8 @@ pub(crate) fn parse_v1_endpoints(json: &serde_json::Value) -> Result<Vec<ModelEn
|
||||
}
|
||||
|
||||
Some(ModelEntry {
|
||||
id: name.clone(),
|
||||
name,
|
||||
name: curated_model_name(&name),
|
||||
id: name,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -370,8 +384,8 @@ pub(crate) fn parse_v2_endpoints_page(
|
||||
}
|
||||
Some(V2Endpoint {
|
||||
entry: ModelEntry {
|
||||
id: name.clone(),
|
||||
name,
|
||||
name: curated_model_name(&name),
|
||||
id: name,
|
||||
},
|
||||
created_ms: endpoint_created_ms(endpoint),
|
||||
})
|
||||
@@ -648,12 +662,61 @@ mod tests {
|
||||
let models = authenticated_empty_v2_catalog();
|
||||
let ids: Vec<&str> = models.iter().map(|model| model.id.as_str()).collect();
|
||||
|
||||
assert_eq!(ids, DATABRICKS_V2_KNOWN_MODELS);
|
||||
let known: Vec<&str> = crate::model_capabilities::databricks_v2_known_models()
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
assert_eq!(ids, known);
|
||||
// `name` is the curated label + provenance suffix, not the raw id.
|
||||
assert!(models.iter().all(|model| {
|
||||
model.name == format!("{}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}", model.id)
|
||||
let label = crate::model_capabilities::databricks_registry_label(&model.id)
|
||||
.unwrap_or(model.id.as_str());
|
||||
model.name == format!("{label}{AUTHENTICATED_EMPTY_CATALOG_SUFFIX}")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_parse_curates_known_name_and_passes_unknown_through() {
|
||||
// buzz-agent's real discovery contract: the endpoint id IS the name the
|
||||
// API returns. A known id gets its manifest label; an unknown id stays raw.
|
||||
let json = serde_json::json!({
|
||||
"endpoints": [
|
||||
{"name": "databricks-gpt-5-5"},
|
||||
{"name": "custom-unlisted-endpoint"},
|
||||
]
|
||||
});
|
||||
let (models, _) = parse_v2_endpoints_page(&json).unwrap();
|
||||
let by_id: std::collections::HashMap<&str, &str> = models
|
||||
.iter()
|
||||
.map(|m| (m.entry.id.as_str(), m.entry.name.as_str()))
|
||||
.collect();
|
||||
assert_eq!(by_id["databricks-gpt-5-5"], "GPT-5.5");
|
||||
assert_eq!(
|
||||
by_id["custom-unlisted-endpoint"],
|
||||
"custom-unlisted-endpoint"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_parse_curates_known_name_and_passes_unknown_through() {
|
||||
let json = serde_json::json!({
|
||||
"endpoints": [
|
||||
{"name": "databricks-gpt-5-5", "task": "llm/v1/chat"},
|
||||
{"name": "custom-unlisted-endpoint", "task": "llm/v1/chat"},
|
||||
]
|
||||
});
|
||||
let models = parse_v1_endpoints(&json).unwrap();
|
||||
let by_id: std::collections::HashMap<&str, &str> = models
|
||||
.iter()
|
||||
.map(|m| (m.id.as_str(), m.name.as_str()))
|
||||
.collect();
|
||||
assert_eq!(by_id["databricks-gpt-5-5"], "GPT-5.5");
|
||||
assert_eq!(
|
||||
by_id["custom-unlisted-endpoint"],
|
||||
"custom-unlisted-endpoint"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_chat_capable_endpoint_keeps_unrecognised_names() {
|
||||
// Prefer including over silently dropping — an unknown family is kept.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,10 +8,11 @@ mod handoff;
|
||||
mod hints;
|
||||
mod llm;
|
||||
mod mcp;
|
||||
pub mod model_capabilities;
|
||||
pub mod types;
|
||||
mod wire;
|
||||
|
||||
pub use catalog::{discover_databricks_models, ModelEntry, DATABRICKS_V2_KNOWN_MODELS};
|
||||
pub use catalog::{discover_databricks_models, ModelEntry};
|
||||
pub use config::Provider;
|
||||
pub use types::AgentError;
|
||||
|
||||
@@ -339,12 +340,15 @@ async fn resolve_models_catalog(
|
||||
///
|
||||
/// This value is never written to `models_cache`; failed discovery must be retried by
|
||||
/// the next session rather than pinning degraded state for the process lifetime.
|
||||
///
|
||||
/// Only reached from the Databricks provider arm below, so the curated label is
|
||||
/// looked up from the Databricks manifest; `id` stays the raw configured value.
|
||||
fn configured_model_fallback(model: &str) -> Vec<ModelEntry> {
|
||||
let model = model.trim().to_string();
|
||||
vec![ModelEntry {
|
||||
id: model.clone(),
|
||||
name: model,
|
||||
}]
|
||||
let name = crate::model_capabilities::databricks_registry_label(&model)
|
||||
.unwrap_or(&model)
|
||||
.to_string();
|
||||
vec![ModelEntry { id: model, name }]
|
||||
}
|
||||
|
||||
async fn session_new(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSender) {
|
||||
@@ -1010,6 +1014,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn configured_model_fallback_is_trimmed_and_singular() {
|
||||
// Unknown id: trimmed, and the raw id passes through as the name.
|
||||
assert_eq!(
|
||||
crate::configured_model_fallback(" configured-model "),
|
||||
vec![ModelEntry {
|
||||
@@ -1018,4 +1023,17 @@ mod tests {
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_model_fallback_curates_known_databricks_id() {
|
||||
// A configured Databricks id known to the manifest gets its curated
|
||||
// label; `id` stays the raw wire/config value.
|
||||
assert_eq!(
|
||||
crate::configured_model_fallback("databricks-gpt-5-5"),
|
||||
vec![ModelEntry {
|
||||
id: "databricks-gpt-5-5".into(),
|
||||
name: "GPT-5.5".into(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::auth::{PkceOAuthConfig, PkceOAuthTokenSource, StaticTokenSource, TokenSource};
|
||||
use crate::config::{
|
||||
is_openai_host, normalize_effort_for_anthropic_route, normalize_effort_for_openai_route,
|
||||
Config, OpenAiApi, Provider, ThinkingEffort,
|
||||
is_openai_host, normalize_effort_for_anthropic_route, normalize_effort_for_databricks_v2,
|
||||
normalize_effort_for_provider, Config, OpenAiApi, Provider, ThinkingEffort,
|
||||
};
|
||||
use crate::types::{
|
||||
AgentError, HistoryItem, LlmResponse, ProviderStop, ToolCall, ToolDef, ToolResultContent,
|
||||
@@ -89,7 +89,15 @@ impl Llm {
|
||||
Provider::Anthropic => self
|
||||
.post_anthropic(
|
||||
cfg,
|
||||
&anthropic_body(cfg, system_prompt, history, tools, effective_model, effort),
|
||||
&anthropic_body(
|
||||
cfg,
|
||||
system_prompt,
|
||||
history,
|
||||
tools,
|
||||
effective_model,
|
||||
effort,
|
||||
"anthropic",
|
||||
),
|
||||
)
|
||||
.await
|
||||
.and_then(parse_anthropic),
|
||||
@@ -107,12 +115,19 @@ impl Llm {
|
||||
.and_then(parse_openai_with_reasoning_details)
|
||||
}
|
||||
Provider::OpenAi | Provider::Databricks => {
|
||||
let provider_str = match cfg.provider {
|
||||
Provider::OpenAi => "openai",
|
||||
Provider::Databricks => "databricks",
|
||||
_ => unreachable!(),
|
||||
};
|
||||
self.openai_request(cfg, effective_model, |use_responses, request_model| {
|
||||
// Normalize effort for model-specific availability. Startup no longer rejects
|
||||
// `max` for pure OpenAI/Databricks; this per-model table is the single authority
|
||||
// — it keeps `max` for gpt-5.6, clamps `max`→`xhigh` for other OpenAI-shaped
|
||||
// models, and still applies corrections like none→minimal on the gpt-5 base.
|
||||
let e = effort.map(|ef| normalize_effort_for_openai_route(ef, request_model));
|
||||
// Normalize effort via the manifest: resolve the actual provider/model
|
||||
// record and apply resolve_openai_effort over its supported_efforts.
|
||||
// Adopted exact-record corrections (e.g. databricks-gpt-5-4-mini →
|
||||
// [low,medium,high]) are enforced here; the openai fallback's effort set
|
||||
// carries the former "unknown model: max→xhigh, others pass" behavior.
|
||||
let e = effort
|
||||
.map(|ef| normalize_effort_for_provider(provider_str, request_model, ef));
|
||||
if use_responses {
|
||||
(
|
||||
responses_body(cfg, system_prompt, history, tools, request_model, e),
|
||||
@@ -130,9 +145,9 @@ impl Llm {
|
||||
Provider::DatabricksV2 => {
|
||||
self.databricks_v2_request(cfg, effective_model, |route| match route {
|
||||
DatabricksV2Route::OpenAiResponses => {
|
||||
// OpenAI Responses path: normalize effort against the per-model table.
|
||||
let e =
|
||||
effort.map(|ef| normalize_effort_for_openai_route(ef, effective_model));
|
||||
// OpenAI Responses path: normalize effort via manifest normalization_policy.
|
||||
let e = effort
|
||||
.map(|ef| normalize_effort_for_databricks_v2(ef, effective_model));
|
||||
(
|
||||
responses_body(cfg, system_prompt, history, tools, effective_model, e),
|
||||
parse_responses as OpenAiParse,
|
||||
@@ -142,14 +157,22 @@ impl Llm {
|
||||
// Anthropic Messages path: normalize effort (none|minimal → omit).
|
||||
let e = effort.and_then(normalize_effort_for_anthropic_route);
|
||||
(
|
||||
anthropic_body(cfg, system_prompt, history, tools, effective_model, e),
|
||||
anthropic_body(
|
||||
cfg,
|
||||
system_prompt,
|
||||
history,
|
||||
tools,
|
||||
effective_model,
|
||||
e,
|
||||
"databricks_v2",
|
||||
),
|
||||
parse_anthropic as OpenAiParse,
|
||||
)
|
||||
}
|
||||
DatabricksV2Route::MlflowChatCompletions => {
|
||||
// MLflow Chat path (OpenAI-shaped): normalize effort against the per-model table.
|
||||
let e =
|
||||
effort.map(|ef| normalize_effort_for_openai_route(ef, effective_model));
|
||||
// MLflow Chat path (OpenAI-shaped): normalize effort via manifest.
|
||||
let e = effort
|
||||
.map(|ef| normalize_effort_for_databricks_v2(ef, effective_model));
|
||||
(
|
||||
openai_body(cfg, system_prompt, history, tools, effective_model, e),
|
||||
parse_openai as OpenAiParse,
|
||||
@@ -425,7 +448,7 @@ impl Llm {
|
||||
where
|
||||
F: FnOnce(DatabricksV2Route) -> (Value, OpenAiParse) + Send,
|
||||
{
|
||||
let route = databricks_v2_route_for_model(effective_model);
|
||||
let route = databricks_v2_route(effective_model);
|
||||
let (body, parse) = build(route);
|
||||
parse(
|
||||
self.post_openai(cfg, databricks_v2_path(route), &body, effective_model)
|
||||
@@ -545,6 +568,7 @@ fn anthropic_body(
|
||||
tools: &[ToolDef],
|
||||
effective_model: &str,
|
||||
effort: Option<ThinkingEffort>,
|
||||
provider: &str,
|
||||
) -> Value {
|
||||
let mut messages: Vec<Value> = Vec::new();
|
||||
let mut pending: Vec<Value> = Vec::new();
|
||||
@@ -616,8 +640,12 @@ fn anthropic_body(
|
||||
let mut body = json!({ "model": effective_model, "max_tokens": cfg.max_output_tokens,
|
||||
"system": system_value, "messages": messages });
|
||||
if let Some(e) = effort {
|
||||
let (thinking, output_config) =
|
||||
crate::config::anthropic_thinking_config(effective_model, e, cfg.max_output_tokens);
|
||||
let (thinking, output_config) = crate::config::anthropic_thinking_config(
|
||||
provider,
|
||||
effective_model,
|
||||
e,
|
||||
cfg.max_output_tokens,
|
||||
);
|
||||
if let Some(t) = thinking {
|
||||
body["thinking"] = t;
|
||||
}
|
||||
@@ -938,56 +966,33 @@ 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 {
|
||||
// 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 has_named_segment(DATABRICKS_V2_CLAUDE_NAMES) {
|
||||
DatabricksV2Route::AnthropicMessages
|
||||
} else {
|
||||
DatabricksV2Route::MlflowChatCompletions
|
||||
/// Resolve the Databricks v2 AI Gateway wire route for `model` from the manifest.
|
||||
///
|
||||
/// The route is a capability of the `(databricks_v2, model)` pair, owned by
|
||||
/// `scripts/model-capabilities.json` and resolved by the shared interpreter — the
|
||||
/// same authority that drives effort/label resolution. This function only maps the
|
||||
/// manifest's route enum onto the three concrete wire routes this dispatch path can
|
||||
/// serve; it holds no routing knowledge of its own.
|
||||
///
|
||||
/// The manifest enum carries two non-wire variants that cannot occur here for a
|
||||
/// concrete Databricks v2 model at dispatch time:
|
||||
/// - `NotApplicable` is produced only for non-`databricks_v2` providers, and this
|
||||
/// seam is reached only under `Provider::DatabricksV2`.
|
||||
/// - `RouteUnknown` is produced only for a blank model id, which `Config` rejects at
|
||||
/// startup (`DATABRICKS_MODEL` required) and `session/set_model` rejects at runtime
|
||||
/// (empty `modelId` → `invalid_params`), so `effective_model` is never blank here.
|
||||
///
|
||||
/// Both are folded into `MlflowChatCompletions` — the manifest's own concrete-unknown
|
||||
/// fallback and the route a blank id would historically have taken — so an unforeseen
|
||||
/// reshape degrades to the safe OpenAI-wire route rather than panicking.
|
||||
fn databricks_v2_route(model: &str) -> DatabricksV2Route {
|
||||
use crate::model_capabilities::DatabricksV2Route as Manifest;
|
||||
match crate::model_capabilities::resolve("databricks_v2", model).databricks_v2_wire_route {
|
||||
Manifest::OpenaiResponses => DatabricksV2Route::OpenAiResponses,
|
||||
Manifest::AnthropicMessages => DatabricksV2Route::AnthropicMessages,
|
||||
Manifest::MlflowChat | Manifest::NotApplicable | Manifest::RouteUnknown => {
|
||||
DatabricksV2Route::MlflowChatCompletions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2860,6 +2865,7 @@ mod tests {
|
||||
&[],
|
||||
"model",
|
||||
None,
|
||||
"anthropic",
|
||||
);
|
||||
let content = &body["messages"][2]["content"][0]["content"];
|
||||
assert_eq!(content[0]["type"], "text");
|
||||
@@ -3144,8 +3150,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn databricks_v2_routes_by_model_family() {
|
||||
fn databricks_v2_dispatch_routes_from_manifest() {
|
||||
use DatabricksV2Route::{AnthropicMessages, MlflowChatCompletions, OpenAiResponses};
|
||||
// Exercises the production dispatch seam (`databricks_v2_route` +
|
||||
// `databricks_v2_path`), not the interpreter — these are the exact
|
||||
// functions `databricks_v2_request` calls to pick a wire. Expected
|
||||
// values are the manifest-ratified answers (corpus class F et al.), so
|
||||
// this is the wire-visible contract, not a restatement of the resolver.
|
||||
for (model, route, path) in [
|
||||
// OpenAI-shaped: the gpt family plus the GPT-5 code names.
|
||||
(
|
||||
@@ -3153,9 +3164,6 @@ mod tests {
|
||||
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,
|
||||
@@ -3166,66 +3174,43 @@ mod tests {
|
||||
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".
|
||||
// Anthropic-shaped: curated `databricks-claude-*` names keep the
|
||||
// cache-capable Messages wire via the manifest prefix rule.
|
||||
(
|
||||
"databricks-claude-opus-4-7",
|
||||
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",
|
||||
),
|
||||
// WIRE-VISIBLE CHANGE (corpus class F): an *uncurated* Claude
|
||||
// code-name endpoint no longer routes to Anthropic Messages. The
|
||||
// legacy segment classifier sent `goose-opus-5` to the cache-capable
|
||||
// wire off the bare `opus` segment; the manifest treats only
|
||||
// curated `databricks-claude-*` / exact records as Anthropic, so
|
||||
// bare code names fall to the MLflow chat route (losing Anthropic
|
||||
// prompt caching on those names). See the mutation guard below.
|
||||
(
|
||||
"goose-opus-5",
|
||||
MlflowChatCompletions,
|
||||
"/ai-gateway/mlflow/v1/chat/completions",
|
||||
),
|
||||
(
|
||||
"opus-5",
|
||||
MlflowChatCompletions,
|
||||
"/ai-gateway/mlflow/v1/chat/completions",
|
||||
),
|
||||
// Unrecognised names still fall through to the MLflow chat route.
|
||||
(
|
||||
"custom-tool-model",
|
||||
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,
|
||||
@@ -3247,12 +3232,65 @@ mod tests {
|
||||
"/ai-gateway/mlflow/v1/chat/completions",
|
||||
),
|
||||
] {
|
||||
let got = databricks_v2_route_for_model(model);
|
||||
let got = databricks_v2_route(model);
|
||||
assert_eq!(got, route, "model={model}");
|
||||
assert_eq!(databricks_v2_path(got), path, "model={model}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn databricks_v2_dispatch_is_pure_manifest_projection() {
|
||||
// Mutation-bypass guard: the dispatch seam must be a pure projection of
|
||||
// the manifest's resolved `databricks_v2_wire_route`, with no routing
|
||||
// decision of its own. For every known DBv2 model (plus the legacy
|
||||
// collision-guard and code-name cases), the seam's wire choice must
|
||||
// equal the enum mapping of `resolve(...).databricks_v2_wire_route`.
|
||||
//
|
||||
// Reintroducing the deleted segment classifier — or any `if
|
||||
// model.contains("opus")`-style shortcut that bypasses the manifest —
|
||||
// disagrees with the manifest on `goose-opus-5` (segment → Anthropic,
|
||||
// manifest → MLflow) and fails this test.
|
||||
use crate::model_capabilities::{resolve, DatabricksV2Route as Manifest};
|
||||
let expected = |model: &str| match resolve("databricks_v2", model).databricks_v2_wire_route
|
||||
{
|
||||
Manifest::OpenaiResponses => DatabricksV2Route::OpenAiResponses,
|
||||
Manifest::AnthropicMessages => DatabricksV2Route::AnthropicMessages,
|
||||
Manifest::MlflowChat | Manifest::NotApplicable | Manifest::RouteUnknown => {
|
||||
DatabricksV2Route::MlflowChatCompletions
|
||||
}
|
||||
};
|
||||
let mut models: Vec<String> =
|
||||
crate::model_capabilities::databricks_v2_known_models().to_vec();
|
||||
// Uncurated / adversarial names the known-model list does not carry, so
|
||||
// the guard covers the exact inputs the legacy classifier misrouted.
|
||||
for extra in [
|
||||
"goose-opus-5",
|
||||
"opus-5",
|
||||
"goose-claude-fable-5",
|
||||
"consolidated-llama",
|
||||
"terraform-coder",
|
||||
"corpus-reranker",
|
||||
"octopus-model",
|
||||
"gpt-opus-5",
|
||||
] {
|
||||
models.push(extra.to_string());
|
||||
}
|
||||
for model in &models {
|
||||
assert_eq!(
|
||||
databricks_v2_route(model),
|
||||
expected(model),
|
||||
"dispatch seam diverged from manifest authority for model={model}"
|
||||
);
|
||||
}
|
||||
// The class-F case, stated as a hard fact so the guard's intent is
|
||||
// legible: the manifest routes `goose-opus-5` to the MLflow wire, and
|
||||
// the seam agrees — the legacy Anthropic answer is gone.
|
||||
assert_eq!(
|
||||
databricks_v2_route("goose-opus-5"),
|
||||
DatabricksV2Route::MlflowChatCompletions
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_responses_rejects_malformed_function_arguments() {
|
||||
let v = serde_json::json!({
|
||||
@@ -3394,6 +3432,7 @@ mod tests {
|
||||
&[],
|
||||
"databricks-claude-opus-5",
|
||||
None,
|
||||
"databricks_v2",
|
||||
);
|
||||
// Static prefix: system promoted to a structured block carrying the marker.
|
||||
assert_eq!(body["system"][0]["type"], "text");
|
||||
@@ -3424,6 +3463,7 @@ mod tests {
|
||||
&[],
|
||||
"databricks-claude-opus-5",
|
||||
None,
|
||||
"databricks_v2",
|
||||
);
|
||||
let msgs = body["messages"].as_array().unwrap();
|
||||
assert_eq!(msgs.len(), 1);
|
||||
@@ -3444,6 +3484,7 @@ mod tests {
|
||||
&[],
|
||||
"databricks-claude-opus-5",
|
||||
None,
|
||||
"databricks_v2",
|
||||
);
|
||||
// system stays a bare string; no marker anywhere.
|
||||
assert_eq!(body["system"], "sys");
|
||||
@@ -3462,6 +3503,7 @@ mod tests {
|
||||
&[],
|
||||
"databricks-claude-opus-5",
|
||||
None,
|
||||
"databricks_v2",
|
||||
);
|
||||
assert_eq!(body["system"], "");
|
||||
assert_eq!(
|
||||
@@ -3481,6 +3523,7 @@ mod tests {
|
||||
&[],
|
||||
"model",
|
||||
None,
|
||||
"anthropic",
|
||||
);
|
||||
assert!(
|
||||
body.get("thinking").is_none(),
|
||||
@@ -3501,6 +3544,7 @@ mod tests {
|
||||
&[],
|
||||
"claude-3-7-sonnet-20250219",
|
||||
Some(ThinkingEffort::High),
|
||||
"anthropic",
|
||||
);
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
// budget_tokens = min(32768, 4096-1024) = 3072
|
||||
@@ -3520,6 +3564,7 @@ mod tests {
|
||||
&[],
|
||||
"claude-3-7-sonnet-20250219",
|
||||
Some(ThinkingEffort::High),
|
||||
"anthropic",
|
||||
);
|
||||
assert!(
|
||||
body.get("thinking").is_none(),
|
||||
@@ -3539,6 +3584,7 @@ mod tests {
|
||||
&[],
|
||||
"claude-3-7-sonnet-20250219",
|
||||
Some(ThinkingEffort::High),
|
||||
"anthropic",
|
||||
);
|
||||
let t = body
|
||||
.get("thinking")
|
||||
@@ -3558,6 +3604,7 @@ mod tests {
|
||||
&[],
|
||||
"claude-3-7-sonnet-20250219",
|
||||
Some(ThinkingEffort::High),
|
||||
"anthropic",
|
||||
);
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 32_768);
|
||||
}
|
||||
@@ -3575,6 +3622,7 @@ mod tests {
|
||||
&[],
|
||||
"claude-3-7-sonnet-20250219",
|
||||
Some(ThinkingEffort::Low),
|
||||
"anthropic",
|
||||
);
|
||||
// Low budget (1024) fits exactly at the boundary — emitted without capping.
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 1024);
|
||||
@@ -3593,6 +3641,7 @@ mod tests {
|
||||
&[],
|
||||
"claude-opus-4-7",
|
||||
Some(ThinkingEffort::High),
|
||||
"anthropic",
|
||||
);
|
||||
assert_eq!(
|
||||
body["thinking"]["type"], "adaptive",
|
||||
@@ -3614,6 +3663,7 @@ mod tests {
|
||||
&[],
|
||||
"claude-opus-4-5",
|
||||
Some(ThinkingEffort::High),
|
||||
"anthropic",
|
||||
);
|
||||
assert_eq!(body["thinking"]["type"], "enabled");
|
||||
assert_eq!(body["thinking"]["budget_tokens"], 31_744); // min(32768, 32768-1024)
|
||||
@@ -3633,6 +3683,7 @@ mod tests {
|
||||
&[],
|
||||
"gpt-4o",
|
||||
Some(ThinkingEffort::High),
|
||||
"anthropic",
|
||||
);
|
||||
assert!(body.get("thinking").is_none(), "thinking must be absent");
|
||||
assert!(
|
||||
@@ -3777,6 +3828,7 @@ mod tests {
|
||||
&[],
|
||||
"override-model",
|
||||
None,
|
||||
"anthropic",
|
||||
);
|
||||
assert_eq!(body["model"], "override-model");
|
||||
}
|
||||
@@ -3806,6 +3858,7 @@ mod tests {
|
||||
&[],
|
||||
"claude-opus-4-8",
|
||||
Some(ThinkingEffort::XHigh),
|
||||
"anthropic",
|
||||
);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert_eq!(body["output_config"]["effort"], "xhigh");
|
||||
@@ -3823,6 +3876,7 @@ mod tests {
|
||||
&[],
|
||||
"claude-opus-4-8",
|
||||
Some(ThinkingEffort::Max),
|
||||
"anthropic",
|
||||
);
|
||||
assert_eq!(body["thinking"]["type"], "adaptive");
|
||||
assert_eq!(body["output_config"]["effort"], "max");
|
||||
@@ -3886,17 +3940,17 @@ mod tests {
|
||||
|
||||
// ---- DatabricksV2 route-aware effort normalization (body-level assertions) ----
|
||||
//
|
||||
// The DBv2 `complete()` dispatch applies `normalize_effort_for_openai_route` /
|
||||
// The DBv2 `complete()` dispatch applies `normalize_effort_for_databricks_v2` /
|
||||
// `normalize_effort_for_anthropic_route` before calling body builders. These tests
|
||||
// verify the body shape that results from the already-normalized effort values — i.e.,
|
||||
// they confirm the body builders correctly serialize the values the dispatch passes them.
|
||||
|
||||
#[test]
|
||||
fn dbv2_openai_route_max_effort_clamped_to_xhigh_in_responses_body() {
|
||||
// DBv2 GPT-5.5 route: max → clamped to xhigh by normalize_effort_for_openai_route
|
||||
// DBv2 GPT-5.5 route: max → clamped to xhigh by normalize_effort_for_databricks_v2
|
||||
// before reaching responses_body. gpt-5.5 supports xhigh so the final value is xhigh.
|
||||
let clamped =
|
||||
crate::config::normalize_effort_for_openai_route(ThinkingEffort::Max, "gpt-5.5");
|
||||
crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Max, "gpt-5.5");
|
||||
let body = responses_body(
|
||||
&cfg_responses(),
|
||||
"system",
|
||||
@@ -3914,7 +3968,7 @@ mod tests {
|
||||
#[test]
|
||||
fn dbv2_openai_route_max_effort_passes_through_for_gpt5_6() {
|
||||
let normalized =
|
||||
crate::config::normalize_effort_for_openai_route(ThinkingEffort::Max, "gpt-5.6-sol");
|
||||
crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Max, "gpt-5.6-sol");
|
||||
let body = responses_body(
|
||||
&cfg_responses(),
|
||||
"system",
|
||||
@@ -3931,10 +3985,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn dbv2_mlflow_route_max_effort_clamped_to_xhigh_in_openai_body() {
|
||||
// DBv2 MLflow route (unknown model): max → clamped to xhigh by normalize_effort_for_openai_route.
|
||||
// DBv2 MLflow route (unknown model): max → clamped to xhigh by normalize_effort_for_databricks_v2.
|
||||
// Unknown models pass through after the max→xhigh clamp.
|
||||
let clamped =
|
||||
crate::config::normalize_effort_for_openai_route(ThinkingEffort::Max, "llama-4");
|
||||
crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Max, "llama-4");
|
||||
let body = openai_body(
|
||||
&cfg(Provider::OpenAi),
|
||||
"system",
|
||||
@@ -3954,14 +4008,14 @@ mod tests {
|
||||
// Verify that supported values pass through for the respective model families.
|
||||
// gpt-5.5 supports none (but not minimal); gpt-5 base supports minimal (but not none).
|
||||
let none_normalized =
|
||||
crate::config::normalize_effort_for_openai_route(ThinkingEffort::None, "gpt-5.5");
|
||||
crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::None, "gpt-5.5");
|
||||
assert_eq!(
|
||||
none_normalized,
|
||||
ThinkingEffort::None,
|
||||
"OpenAI normalizer must not touch none for gpt-5.5"
|
||||
);
|
||||
let minimal_normalized =
|
||||
crate::config::normalize_effort_for_openai_route(ThinkingEffort::Minimal, "gpt-5");
|
||||
crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Minimal, "gpt-5");
|
||||
assert_eq!(
|
||||
minimal_normalized,
|
||||
ThinkingEffort::Minimal,
|
||||
@@ -3998,6 +4052,7 @@ mod tests {
|
||||
&[],
|
||||
"claude-opus-4-8",
|
||||
normalized, // None → omit thinking fields
|
||||
"anthropic",
|
||||
);
|
||||
assert!(
|
||||
body.get("thinking").is_none(),
|
||||
@@ -4022,6 +4077,7 @@ mod tests {
|
||||
|
||||
// Before switch: claude-opus-4-8 with effort=max → adaptive shape, effort="max"
|
||||
let (thinking_before, oc_before) = crate::config::anthropic_thinking_config(
|
||||
"anthropic",
|
||||
"claude-opus-4-8",
|
||||
ThinkingEffort::Max,
|
||||
32_768,
|
||||
@@ -4032,7 +4088,7 @@ mod tests {
|
||||
// After switch to GPT-5.5 route: normalize max → xhigh for responses_body
|
||||
// (gpt-5.5 supports xhigh, so the clamp result is xhigh, not further reduced)
|
||||
let clamped =
|
||||
crate::config::normalize_effort_for_openai_route(ThinkingEffort::Max, "gpt-5.5");
|
||||
crate::config::normalize_effort_for_databricks_v2(ThinkingEffort::Max, "gpt-5.5");
|
||||
assert_eq!(clamped, ThinkingEffort::XHigh);
|
||||
let body_after = responses_body(
|
||||
&cfg_responses(),
|
||||
@@ -6811,6 +6867,7 @@ mod tests {
|
||||
&[],
|
||||
"claude-opus-4-7",
|
||||
None,
|
||||
"anthropic",
|
||||
);
|
||||
let messages = body["messages"].as_array().unwrap();
|
||||
let assistant = messages
|
||||
|
||||
899
crates/buzz-agent/src/model_capabilities.rs
Normal file
899
crates/buzz-agent/src/model_capabilities.rs
Normal file
@@ -0,0 +1,899 @@
|
||||
//! Runtime model-capability interpreter.
|
||||
//!
|
||||
//! `scripts/model-capabilities.json` is the single source of truth for every
|
||||
//! model's six-axis capability profile (thinking mode, supported efforts,
|
||||
//! default effort, Databricks v2 wire route, normalization policy, and picker
|
||||
//! label). It is embedded at compile time (`include_str!`), parsed once through
|
||||
//! strict `serde` (`deny_unknown_fields` + real enums), and cached in a
|
||||
//! [`OnceLock`]. No codegen: both this interpreter and the TypeScript one in
|
||||
//! `desktop/` read the same hand-curated manifest, and the shared normative
|
||||
//! corpus (`scripts/normative-corpus.json`) is the cross-language contract that
|
||||
//! guarantees they agree.
|
||||
//!
|
||||
//! ## Resolution algorithm (`resolve`)
|
||||
//! 1. Provider canonicalization happens *inside* the resolver: trim, lowercase,
|
||||
//! and apply the alias map (`openai-compat` → `openai`,
|
||||
//! `databricks-v2` → `databricks_v2`).
|
||||
//! 2. Provider-qualified exact-record lookup (case-insensitive on the model id).
|
||||
//! 3. Boundary-aware family-rule match: strip any endpoint prefix at the first
|
||||
//! family token on a non-alphanumeric boundary, then take the longest match
|
||||
//! across every rule's `match_value` and `match_aliases`, breaking ties on
|
||||
//! the lexicographically smallest rule id.
|
||||
//! 4. Provider fallback, distinguishing a blank model id from a concrete-unknown
|
||||
//! one.
|
||||
//!
|
||||
//! Every path yields a complete six-axis result; `registry_label` is populated
|
||||
//! only on an exact-record hit.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::ThinkingEffort;
|
||||
|
||||
/// How a model activates and controls reasoning depth on the wire.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ThinkingMode {
|
||||
Adaptive,
|
||||
ManualBudget,
|
||||
None,
|
||||
OmitFields,
|
||||
}
|
||||
|
||||
/// The Databricks v2 AI Gateway wire route a model is served on. `NotApplicable`
|
||||
/// marks non-Databricks providers; `RouteUnknown` marks a blank Databricks id.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum DatabricksV2Route {
|
||||
AnthropicMessages,
|
||||
MlflowChat,
|
||||
NotApplicable,
|
||||
OpenaiResponses,
|
||||
RouteUnknown,
|
||||
}
|
||||
|
||||
/// Post-resolution effort normalization applied before a request is sent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum NormalizationPolicy {
|
||||
None,
|
||||
OpenaiClampMaxToXhigh,
|
||||
OpenaiStandard,
|
||||
}
|
||||
|
||||
/// Whether a family rule matches its token exactly or as a boundary-aware prefix.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
enum MatchKind {
|
||||
Exact,
|
||||
Prefix,
|
||||
}
|
||||
|
||||
/// A family/prefix rule: matches a canonical (prefix-stripped) model id against
|
||||
/// `match_value` or any `match_aliases` token for the listed providers.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FamilyRule {
|
||||
id: String,
|
||||
match_kind: MatchKind,
|
||||
match_value: String,
|
||||
#[serde(default)]
|
||||
match_aliases: Vec<String>,
|
||||
providers: Vec<String>,
|
||||
thinking_mode: ThinkingMode,
|
||||
supported_efforts: Vec<ThinkingEffort>,
|
||||
default_effort: Option<ThinkingEffort>,
|
||||
databricks_v2_wire_route: DatabricksV2Route,
|
||||
normalization_policy: NormalizationPolicy,
|
||||
/// Documentation only; modeled so `deny_unknown_fields` accepts the manifest.
|
||||
#[serde(rename = "_comment", default)]
|
||||
#[allow(dead_code)]
|
||||
comment: Option<String>,
|
||||
}
|
||||
|
||||
/// An authoritative six-axis snapshot for one concrete `(provider, model)` pair.
|
||||
/// Exact records do *not* inherit from family rules at runtime; the doc fields
|
||||
/// record the one-time provenance of each axis (see the manifest `_comment`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ExactRecord {
|
||||
provider: String,
|
||||
raw_model_id: String,
|
||||
registry_label: String,
|
||||
thinking_mode: ThinkingMode,
|
||||
supported_efforts: Vec<ThinkingEffort>,
|
||||
default_effort: Option<ThinkingEffort>,
|
||||
databricks_v2_wire_route: DatabricksV2Route,
|
||||
normalization_policy: NormalizationPolicy,
|
||||
// Documentation/provenance keys; modeled for strict parsing, not read at runtime.
|
||||
#[serde(rename = "_provenance", default)]
|
||||
#[allow(dead_code)]
|
||||
provenance: Option<String>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
source: Option<String>,
|
||||
#[serde(rename = "_source", default)]
|
||||
#[allow(dead_code)]
|
||||
source_alt: Option<String>,
|
||||
#[serde(rename = "_reconciliation", default)]
|
||||
#[allow(dead_code)]
|
||||
reconciliation: Option<String>,
|
||||
#[serde(rename = "_reconciliation_note", default)]
|
||||
#[allow(dead_code)]
|
||||
reconciliation_note: Option<String>,
|
||||
#[serde(rename = "_reconciliation_doc", default)]
|
||||
#[allow(dead_code)]
|
||||
reconciliation_doc: Option<String>,
|
||||
}
|
||||
|
||||
/// One provider's fallback profiles for a blank vs. a concrete-unknown model id.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FallbackPair {
|
||||
blank: FallbackState,
|
||||
concrete_unknown: FallbackState,
|
||||
}
|
||||
|
||||
/// A five-axis fallback profile (no label — fallbacks never carry one).
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FallbackState {
|
||||
databricks_v2_wire_route: DatabricksV2Route,
|
||||
thinking_mode: ThinkingMode,
|
||||
supported_efforts: Vec<ThinkingEffort>,
|
||||
default_effort: Option<ThinkingEffort>,
|
||||
normalization_policy: NormalizationPolicy,
|
||||
}
|
||||
|
||||
/// Provider fallbacks keyed by canonical provider, with a `_default` catch-all.
|
||||
/// Both states of every provider are required, so "both fallback states present"
|
||||
/// is enforced structurally by the parse.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ProviderFallbacks {
|
||||
anthropic: FallbackPair,
|
||||
openai: FallbackPair,
|
||||
databricks: FallbackPair,
|
||||
databricks_v2: FallbackPair,
|
||||
openrouter: FallbackPair,
|
||||
#[serde(rename = "_default")]
|
||||
default: FallbackPair,
|
||||
}
|
||||
|
||||
impl ProviderFallbacks {
|
||||
/// Fallback pair for a canonical provider, or `_default` for anything else.
|
||||
fn get(&self, provider: &str) -> &FallbackPair {
|
||||
match provider {
|
||||
"anthropic" => &self.anthropic,
|
||||
"openai" => &self.openai,
|
||||
"databricks" => &self.databricks,
|
||||
"databricks_v2" => &self.databricks_v2,
|
||||
"openrouter" => &self.openrouter,
|
||||
_ => &self.default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Named pairs, for validation.
|
||||
fn named(&self) -> [(&str, &FallbackPair); 6] {
|
||||
[
|
||||
("anthropic", &self.anthropic),
|
||||
("openai", &self.openai),
|
||||
("databricks", &self.databricks),
|
||||
("databricks_v2", &self.databricks_v2),
|
||||
("openrouter", &self.openrouter),
|
||||
("_default", &self.default),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The parsed manifest.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Manifest {
|
||||
family_tokens: Vec<String>,
|
||||
family_rules: Vec<FamilyRule>,
|
||||
databricks_v2_known_models: Vec<String>,
|
||||
exact_records: Vec<ExactRecord>,
|
||||
provider_fallbacks: ProviderFallbacks,
|
||||
// Root documentation keys; modeled for strict parsing, not read at runtime.
|
||||
#[serde(rename = "_comment", default)]
|
||||
#[allow(dead_code)]
|
||||
comment: Option<String>,
|
||||
#[serde(rename = "_comment_databricks_v2_known_models", default)]
|
||||
#[allow(dead_code)]
|
||||
comment_known_models: Option<String>,
|
||||
#[serde(rename = "_sources", default)]
|
||||
#[allow(dead_code)]
|
||||
sources: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// The resolved six-axis capability profile for one `(provider, model)` query.
|
||||
/// All fields borrow from the process-lifetime manifest. The field names and
|
||||
/// declaration order are the corpus `expect` schema — the test-only generator
|
||||
/// serializes this struct directly, so there is no second encoding of the axes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub struct CapabilityResult {
|
||||
pub thinking_mode: ThinkingMode,
|
||||
pub supported_efforts: &'static [ThinkingEffort],
|
||||
pub default_effort: Option<ThinkingEffort>,
|
||||
pub databricks_v2_wire_route: DatabricksV2Route,
|
||||
pub normalization_policy: NormalizationPolicy,
|
||||
pub registry_label: Option<&'static str>,
|
||||
}
|
||||
|
||||
const MANIFEST_JSON: &str = include_str!("../../../scripts/model-capabilities.json");
|
||||
|
||||
static MANIFEST: OnceLock<Manifest> = OnceLock::new();
|
||||
|
||||
/// Parse (once) and return the embedded manifest. Panics on a malformed or
|
||||
/// invalid bundled manifest — a build-time data error that must never ship.
|
||||
fn manifest() -> &'static Manifest {
|
||||
MANIFEST.get_or_init(|| {
|
||||
let parsed: Manifest = serde_json::from_str(MANIFEST_JSON)
|
||||
.expect("bundled model-capabilities.json must parse");
|
||||
if let Err(e) = validate_manifest(&parsed) {
|
||||
panic!("bundled model-capabilities.json failed validation: {e}");
|
||||
}
|
||||
parsed
|
||||
})
|
||||
}
|
||||
|
||||
/// Canonicalize a provider name: trim, lowercase, apply the alias map.
|
||||
fn canonical_provider(provider: &str) -> String {
|
||||
let canon = provider.trim().to_ascii_lowercase();
|
||||
match canon.as_str() {
|
||||
"openai-compat" => "openai".to_string(),
|
||||
"databricks-v2" => "databricks_v2".to_string(),
|
||||
_ => canon,
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip an endpoint-naming prefix by locating the earliest family token that
|
||||
/// begins on a non-alphanumeric boundary (or at the start), returning the slice
|
||||
/// from that token onward. Returns the input unchanged when no token qualifies.
|
||||
fn strip_catalog_prefix<'a>(model_lower: &'a str, family_tokens: &[String]) -> &'a str {
|
||||
let bytes = model_lower.as_bytes();
|
||||
let mut best: Option<usize> = None;
|
||||
for tok in family_tokens {
|
||||
let mut from = 0;
|
||||
while let Some(rel) = model_lower[from..].find(tok.as_str()) {
|
||||
let idx = from + rel;
|
||||
if idx == 0 || !bytes[idx - 1].is_ascii_alphanumeric() {
|
||||
best = Some(best.map_or(idx, |b| b.min(idx)));
|
||||
break;
|
||||
}
|
||||
from = idx + 1;
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some(idx) => &model_lower[idx..],
|
||||
None => model_lower,
|
||||
}
|
||||
}
|
||||
|
||||
/// Boundary-aware prefix test: `s` equals `token`, or `s` starts with `token`
|
||||
/// and the following character is a non-alphanumeric boundary.
|
||||
fn prefix_matches(token: &str, s: &str) -> bool {
|
||||
match s.strip_prefix(token) {
|
||||
Some(rest) => rest
|
||||
.chars()
|
||||
.next()
|
||||
.is_none_or(|c| !c.is_ascii_alphanumeric()),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the capability profile for a `(provider, raw_model_id)` pair.
|
||||
pub fn resolve(provider: &str, raw_model_id: &str) -> CapabilityResult {
|
||||
let m = manifest();
|
||||
let canon = canonical_provider(provider);
|
||||
let blank = raw_model_id.trim().is_empty();
|
||||
|
||||
// 1. Provider-qualified exact-record lookup (case-insensitive on the id).
|
||||
if !blank {
|
||||
for rec in &m.exact_records {
|
||||
if rec.provider == canon && rec.raw_model_id.eq_ignore_ascii_case(raw_model_id) {
|
||||
return CapabilityResult {
|
||||
thinking_mode: rec.thinking_mode,
|
||||
supported_efforts: &rec.supported_efforts,
|
||||
default_effort: rec.default_effort,
|
||||
databricks_v2_wire_route: rec.databricks_v2_wire_route,
|
||||
normalization_policy: rec.normalization_policy,
|
||||
registry_label: Some(&rec.registry_label),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Boundary-aware family match: longest token wins, lexicographic tie-break.
|
||||
if !blank {
|
||||
let model_lower = raw_model_id.to_ascii_lowercase();
|
||||
let stripped = strip_catalog_prefix(&model_lower, &m.family_tokens);
|
||||
let mut best: Option<(usize, &FamilyRule)> = None;
|
||||
for rule in &m.family_rules {
|
||||
if !rule.providers.iter().any(|p| p == &canon) {
|
||||
continue;
|
||||
}
|
||||
let mut matched: Option<usize> = None;
|
||||
for tok in std::iter::once(&rule.match_value).chain(rule.match_aliases.iter()) {
|
||||
let ok = match rule.match_kind {
|
||||
MatchKind::Exact => stripped == tok.as_str(),
|
||||
MatchKind::Prefix => prefix_matches(tok, stripped),
|
||||
};
|
||||
if ok {
|
||||
matched = Some(matched.map_or(tok.len(), |l| l.max(tok.len())));
|
||||
}
|
||||
}
|
||||
if let Some(len) = matched {
|
||||
let better = match best {
|
||||
None => true,
|
||||
Some((blen, brule)) => len > blen || (len == blen && rule.id < brule.id),
|
||||
};
|
||||
if better {
|
||||
best = Some((len, rule));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((_, rule)) = best {
|
||||
let route = if canon == "databricks_v2" {
|
||||
rule.databricks_v2_wire_route
|
||||
} else {
|
||||
DatabricksV2Route::NotApplicable
|
||||
};
|
||||
return CapabilityResult {
|
||||
thinking_mode: rule.thinking_mode,
|
||||
supported_efforts: &rule.supported_efforts,
|
||||
default_effort: rule.default_effort,
|
||||
databricks_v2_wire_route: route,
|
||||
normalization_policy: rule.normalization_policy,
|
||||
registry_label: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Provider fallback (blank vs. concrete-unknown); never carries a label.
|
||||
let pair = m.provider_fallbacks.get(&canon);
|
||||
let state = if blank {
|
||||
&pair.blank
|
||||
} else {
|
||||
&pair.concrete_unknown
|
||||
};
|
||||
CapabilityResult {
|
||||
thinking_mode: state.thinking_mode,
|
||||
supported_efforts: &state.supported_efforts,
|
||||
default_effort: state.default_effort,
|
||||
databricks_v2_wire_route: state.databricks_v2_wire_route,
|
||||
normalization_policy: state.normalization_policy,
|
||||
registry_label: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Authoritative list of known Databricks v2 model ids, sourced from the manifest.
|
||||
pub fn databricks_v2_known_models() -> &'static [String] {
|
||||
&manifest().databricks_v2_known_models
|
||||
}
|
||||
|
||||
/// Curated display label for a Databricks endpoint id, or `None` when no exact
|
||||
/// record covers it. Read-only accessor over the same `databricks_v2` exact
|
||||
/// records `resolve()` consults, with the same case-insensitive id match; used
|
||||
/// by discovery to curate `ModelEntry.name` (the Databricks API returns no
|
||||
/// display name of its own). Scoped to `databricks_v2` records only, so it can
|
||||
/// never surface a curated label for a non-Databricks provider.
|
||||
pub fn databricks_registry_label(raw_model_id: &str) -> Option<&'static str> {
|
||||
if raw_model_id.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
manifest()
|
||||
.exact_records
|
||||
.iter()
|
||||
.find(|rec| {
|
||||
rec.provider == "databricks_v2" && rec.raw_model_id.eq_ignore_ascii_case(raw_model_id)
|
||||
})
|
||||
.map(|rec| rec.registry_label.as_str())
|
||||
}
|
||||
|
||||
/// Semantic invariants that strict typed parsing cannot express. Structural
|
||||
/// checks (required fields, enum domains, both fallback states) are already
|
||||
/// guaranteed by `serde` + `deny_unknown_fields`; this owns the rest.
|
||||
fn validate_manifest(m: &Manifest) -> Result<(), String> {
|
||||
if m.family_tokens.is_empty() {
|
||||
return Err("family_tokens must be non-empty".to_string());
|
||||
}
|
||||
|
||||
let check_efforts = |ctx: &str,
|
||||
efforts: &[ThinkingEffort],
|
||||
default: Option<ThinkingEffort>|
|
||||
-> Result<(), String> {
|
||||
if efforts.is_empty() {
|
||||
return Err(format!("{ctx}: supported_efforts must be non-empty"));
|
||||
}
|
||||
// Canonical enum order is None < Minimal < ... < Max; strict ascending
|
||||
// enforces sorted + duplicate-free in one check.
|
||||
if !efforts.windows(2).all(|w| w[0] < w[1]) {
|
||||
return Err(format!(
|
||||
"{ctx}: supported_efforts must be sorted in canonical order with no duplicates"
|
||||
));
|
||||
}
|
||||
if let Some(d) = default {
|
||||
if !efforts.contains(&d) {
|
||||
return Err(format!(
|
||||
"{ctx}: default_effort {d:?} not in supported_efforts"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// Family rules: unique ids, non-empty providers, effort validity, and no
|
||||
// match token (value or alias) shared across or within rules.
|
||||
let mut rule_ids = std::collections::HashSet::new();
|
||||
let mut token_owner: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
|
||||
for rule in &m.family_rules {
|
||||
if !rule_ids.insert(rule.id.as_str()) {
|
||||
return Err(format!("duplicate family rule id: {}", rule.id));
|
||||
}
|
||||
if rule.providers.is_empty() {
|
||||
return Err(format!("family rule {} has empty providers", rule.id));
|
||||
}
|
||||
check_efforts(
|
||||
&format!("family_rule {}", rule.id),
|
||||
&rule.supported_efforts,
|
||||
rule.default_effort,
|
||||
)?;
|
||||
for tok in std::iter::once(&rule.match_value).chain(rule.match_aliases.iter()) {
|
||||
if let Some(prev) = token_owner.insert(tok.as_str(), rule.id.as_str()) {
|
||||
return Err(format!(
|
||||
"duplicate match token {tok:?} (rules {prev} and {})",
|
||||
rule.id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exact records: case-insensitive uniqueness of (provider, id), non-empty
|
||||
// labels, effort validity.
|
||||
let mut exact_keys = std::collections::HashSet::new();
|
||||
for rec in &m.exact_records {
|
||||
let key = (rec.provider.clone(), rec.raw_model_id.to_ascii_lowercase());
|
||||
if !exact_keys.insert(key) {
|
||||
return Err(format!(
|
||||
"duplicate exact record: {} / {}",
|
||||
rec.provider, rec.raw_model_id
|
||||
));
|
||||
}
|
||||
if rec.registry_label.trim().is_empty() {
|
||||
return Err(format!(
|
||||
"exact record {} has an empty registry_label",
|
||||
rec.raw_model_id
|
||||
));
|
||||
}
|
||||
check_efforts(
|
||||
&format!("exact_record {}", rec.raw_model_id),
|
||||
&rec.supported_efforts,
|
||||
rec.default_effort,
|
||||
)?;
|
||||
}
|
||||
|
||||
// Known-model ids: case-insensitive uniqueness.
|
||||
let mut known = std::collections::HashSet::new();
|
||||
for id in &m.databricks_v2_known_models {
|
||||
if id.trim().is_empty() {
|
||||
return Err("databricks_v2_known_models contains an empty id".to_string());
|
||||
}
|
||||
if !known.insert(id.to_ascii_lowercase()) {
|
||||
return Err(format!("duplicate databricks_v2_known_models id: {id}"));
|
||||
}
|
||||
}
|
||||
|
||||
// Provider fallbacks: effort validity for both states of every provider.
|
||||
for (name, pair) in m.provider_fallbacks.named() {
|
||||
check_efforts(
|
||||
&format!("fallback {name}/blank"),
|
||||
&pair.blank.supported_efforts,
|
||||
pair.blank.default_effort,
|
||||
)?;
|
||||
check_efforts(
|
||||
&format!("fallback {name}/concrete_unknown"),
|
||||
&pair.concrete_unknown.supported_efforts,
|
||||
pair.concrete_unknown.default_effort,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One entry of the generator's *inputs-only* table. It encodes **which
|
||||
/// questions to ask** — section headers and `(provider, raw_model_id)`
|
||||
/// query pairs plus a human note — and never any expected answer. Every
|
||||
/// answer is computed by the production [`resolve`] at generation time, so
|
||||
/// the manifest stays the single place capability behavior is encoded.
|
||||
enum Q {
|
||||
Section {
|
||||
group: &'static str,
|
||||
note: Option<&'static str>,
|
||||
},
|
||||
Vector {
|
||||
id: &'static str,
|
||||
provider: &'static str,
|
||||
raw_model_id: &'static str,
|
||||
note: Option<&'static str>,
|
||||
},
|
||||
}
|
||||
|
||||
/// The inputs-only question set (section headers interleaved with query
|
||||
/// vectors, in file order). Answers live only in the manifest; this table
|
||||
/// says which questions to ask. Adding, removing, or reordering a `Vector`
|
||||
/// here changes the generated corpus — run `just regen-model-corpus`.
|
||||
const INPUTS: &[Q] = &[
|
||||
Q::Section { group: "Anthropic curated family-rule model names", note: None },
|
||||
Q::Vector { id: "anthropic-claude-3-family", provider: "anthropic", raw_model_id: "claude-3-7-sonnet-20250219", note: None },
|
||||
Q::Vector { id: "anthropic-claude-opus-4-5", provider: "anthropic", raw_model_id: "claude-opus-4-5", note: None },
|
||||
Q::Vector { id: "anthropic-claude-opus-4-7", provider: "anthropic", raw_model_id: "claude-opus-4-7", note: None },
|
||||
Q::Vector { id: "anthropic-claude-opus-4-8", provider: "anthropic", raw_model_id: "claude-opus-4-8", note: None },
|
||||
Q::Vector { id: "anthropic-claude-sonnet-5", provider: "anthropic", raw_model_id: "claude-sonnet-5-20260101", note: None },
|
||||
Q::Vector { id: "anthropic-claude-fable-5", provider: "anthropic", raw_model_id: "claude-fable-5", note: None },
|
||||
Q::Vector { id: "anthropic-claude-mythos-5", provider: "anthropic", raw_model_id: "claude-mythos-5", note: None },
|
||||
Q::Vector { id: "anthropic-claude-opus-4-6", provider: "anthropic", raw_model_id: "claude-opus-4-6", note: None },
|
||||
Q::Vector { id: "anthropic-claude-sonnet-4-6", provider: "anthropic", raw_model_id: "claude-sonnet-4-6", note: None },
|
||||
Q::Vector { id: "anthropic-claude-mythos-preview", provider: "anthropic", raw_model_id: "claude-mythos-preview", note: None },
|
||||
Q::Section { group: "Anthropic blank and concrete-unknown inputs", note: None },
|
||||
Q::Vector { id: "anthropic-unknown-blank", provider: "anthropic", raw_model_id: "", note: None },
|
||||
Q::Vector { id: "anthropic-unknown-concrete", provider: "anthropic", raw_model_id: "claude-ultra-9000", note: None },
|
||||
Q::Section { group: "OpenAI curated family-rule model names", note: None },
|
||||
Q::Vector { id: "openai-gpt5-pro", provider: "openai", raw_model_id: "gpt-5-pro", note: None },
|
||||
Q::Vector { id: "openai-gpt5.6", provider: "openai", raw_model_id: "gpt-5.6", note: None },
|
||||
Q::Vector { id: "openai-gpt5-6-dashed", provider: "openai", raw_model_id: "gpt-5-6", note: None },
|
||||
Q::Vector { id: "openai-gpt5.5", provider: "openai", raw_model_id: "gpt-5.5", note: None },
|
||||
Q::Vector { id: "openai-gpt5.4", provider: "openai", raw_model_id: "gpt-5.4", note: None },
|
||||
Q::Vector { id: "openai-gpt5.1", provider: "openai", raw_model_id: "gpt-5.1", note: None },
|
||||
Q::Vector { id: "openai-gpt5-base", provider: "openai", raw_model_id: "gpt-5", note: None },
|
||||
Q::Section { group: "OpenAI gpt-5 boundary-matching probes (ported from config.rs tests)", note: None },
|
||||
Q::Vector { id: "openai-gpt5-1106-date-suffix-probe", provider: "openai", raw_model_id: "gpt-5-1106", note: Some("Probes a 4-digit date-shaped suffix after the gpt-5 stem.") },
|
||||
Q::Vector { id: "openai-gpt5-4o-alpha-suffix-probe", provider: "openai", raw_model_id: "gpt-5-4o", note: Some("Probes a leading-digit-then-letter suffix ('4o') after the gpt-5 stem.") },
|
||||
Q::Vector { id: "openai-gpt5-pro-precedence-probe", provider: "openai", raw_model_id: "gpt-5-pro", note: Some("Probes precedence between the gpt-5-pro rule and the gpt-5 base stem.") },
|
||||
Q::Vector { id: "openai-gpt5-10-multi-digit-probe", provider: "openai", raw_model_id: "gpt-5-10", note: Some("Probes a two-digit minor-version suffix after the gpt-5 stem.") },
|
||||
Q::Vector { id: "openai-gpt5-date-suffix-probe", provider: "openai", raw_model_id: "gpt-5-20260101", note: Some("Probes an 8-digit date suffix after the gpt-5 stem.") },
|
||||
Q::Section { group: "DatabricksV2 segment/prefix routing probes (ported from llm.rs tests)", note: None },
|
||||
Q::Vector { id: "dbv2-gpt5-5-probe", provider: "databricks_v2", raw_model_id: "gpt-5.5", note: None },
|
||||
Q::Vector { id: "dbv2-claude-opus-4-7-probe", provider: "databricks_v2", raw_model_id: "claude-opus-4-7", note: None },
|
||||
Q::Vector { id: "dbv2-databricks-prefix-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-4-7", note: Some("Probes stripping of the databricks- catalog prefix.") },
|
||||
Q::Vector { id: "dbv2-goose-claude-prefix-probe", provider: "databricks_v2", raw_model_id: "goose-claude-fable-5", note: Some("Probes stripping of the goose- catalog prefix.") },
|
||||
Q::Vector { id: "dbv2-team-prefix-probe", provider: "databricks_v2", raw_model_id: "team-x-claude-opus-4-7", note: Some("Probes stripping of a team-x- catalog prefix.") },
|
||||
Q::Vector { id: "dbv2-consolidated-llama-substring-probe", provider: "databricks_v2", raw_model_id: "consolidated-llama", note: Some("Probes a name where a code word ('sol') appears only as a substring, not a boundary-aligned segment.") },
|
||||
Q::Vector { id: "dbv2-terraform-coder-substring-probe", provider: "databricks_v2", raw_model_id: "terraform-coder", note: Some("Probes a name where a code word ('terra') is only a segment prefix, not a full segment.") },
|
||||
Q::Vector { id: "dbv2-corpus-reranker-substring-probe", provider: "databricks_v2", raw_model_id: "corpus-reranker", note: Some("Probes a name where 'opus' appears only as a substring of a segment.") },
|
||||
Q::Vector { id: "dbv2-octopus-model-substring-probe", provider: "databricks_v2", raw_model_id: "octopus-model", note: Some("Probes a name where 'opus' appears only as a substring of a segment.") },
|
||||
Q::Vector { id: "dbv2-goose-opus-5-prefix-probe", provider: "databricks_v2", raw_model_id: "goose-opus-5", note: Some("Probes a goose- prefix over a bare code-name segment with no leading claude.") },
|
||||
Q::Section { group: "Resolver-contract probes (plan v4 §Resolver contract)", note: None },
|
||||
Q::Vector { id: "resolver-exact-raw-id-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-4-mini", note: Some("Probes a raw id that has an exact record.") },
|
||||
Q::Vector { id: "resolver-prefixed-alias-probe", provider: "databricks_v2", raw_model_id: "team-x-databricks-gpt-5-4-mini", note: Some("Probes a prefixed alias of an exact-record id (raw exact key differs).") },
|
||||
Q::Vector { id: "resolver-cross-provider-probe", provider: "openai", raw_model_id: "databricks-gpt-5-4-mini", note: Some("Probes the same raw id under a different provider (exact records are provider-scoped).") },
|
||||
Q::Vector { id: "resolver-exact-record-with-family-route-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-6-sol", note: Some("Exact-vs-family route-axis probe (raw exact key with a covering family rule).") },
|
||||
Q::Vector { id: "dbv2-gpt5-5-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-5", note: Some("Exact-vs-family effort-axis probe (exact record overlapping a family rule).") },
|
||||
Q::Section { group: "Blank and concrete-unknown inputs per provider", note: None },
|
||||
Q::Vector { id: "dbv2-blank-probe", provider: "databricks_v2", raw_model_id: "", note: Some("Probes a blank databricks_v2 model id.") },
|
||||
Q::Vector { id: "dbv2-concrete-unknown-probe", provider: "databricks_v2", raw_model_id: "some-unknown-model-xyz", note: Some("Probes a concrete, uncatalogued databricks_v2 model id.") },
|
||||
Q::Vector { id: "openai-blank-probe", provider: "openai", raw_model_id: "", note: Some("Probes a blank openai model id.") },
|
||||
Q::Vector { id: "openai-concrete-unknown-probe", provider: "openai", raw_model_id: "gpt-4o", note: Some("Probes a concrete openai model id in no verified family.") },
|
||||
Q::Vector { id: "anthropic-blank-probe", provider: "anthropic", raw_model_id: "", note: Some("Probes a blank anthropic model id.") },
|
||||
Q::Vector { id: "anthropic-concrete-unknown-probe", provider: "anthropic", raw_model_id: "claude-ultra-9000", note: Some("Probes a concrete, uncatalogued anthropic model id.") },
|
||||
Q::Section { group: "Legacy Databricks provider inputs", note: None },
|
||||
Q::Vector { id: "databricks-gpt5-pro-probe", provider: "databricks", raw_model_id: "databricks-gpt-5-pro", note: Some("Probes the legacy databricks provider with a GPT-5 Pro id.") },
|
||||
Q::Vector { id: "databricks-gpt5-6-probe", provider: "databricks", raw_model_id: "databricks-gpt-5.6", note: Some("Probes the legacy databricks provider with a GPT-5.6 id.") },
|
||||
Q::Vector { id: "databricks-gpt5-1-probe", provider: "databricks", raw_model_id: "databricks-gpt-5.1", note: Some("Probes the legacy databricks provider with a GPT-5.1 id.") },
|
||||
Q::Section { group: "openai-compat alias canonicalization probes", note: Some("Probes whether openai-compat is canonicalized to openai before resolving; both interpreters must agree.") },
|
||||
Q::Vector { id: "openai-compat-gpt-5-pro-probe", provider: "openai-compat", raw_model_id: "gpt-5-pro", note: None },
|
||||
Q::Vector { id: "openai-compat-gpt-5-5-probe", provider: "openai-compat", raw_model_id: "gpt-5.5", note: None },
|
||||
Q::Vector { id: "openai-compat-blank-probe", provider: "openai-compat", raw_model_id: "", note: Some("Probes openai-compat canonicalization with a blank model id.") },
|
||||
Q::Section { group: "gpt-5 short-version-suffix boundary probes (Rust/TS divergence window)", note: Some("Probes the 1-2 digit version-suffix window where the Rust guard and the TS regex historically diverged.") },
|
||||
Q::Vector { id: "openai-gpt5-10-preview-probe", provider: "openai", raw_model_id: "gpt-5-10-preview", note: None },
|
||||
Q::Vector { id: "openai-gpt5-2-mini-probe", provider: "openai", raw_model_id: "gpt-5-2-mini", note: None },
|
||||
Q::Vector { id: "openai-gpt5-9-dot-1-probe", provider: "openai", raw_model_id: "gpt-5-9.1", note: None },
|
||||
Q::Vector { id: "dbv2-gpt5-10-multi-axis-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-10", note: Some("Probes a databricks_v2 gpt-5-<multi-digit> id, exercising both the effort axes and the wire route.") },
|
||||
Q::Vector { id: "dbv2-gpt-5-2-exact-vs-base-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-2", note: Some("Exact-vs-base-stem probe: an exact record coexisting with the gpt-5 base stem rule.") },
|
||||
Q::Vector { id: "openai-customgpt-5-5-nonboundary-probe", provider: "openai", raw_model_id: "customgpt-5-5-endpoint", note: Some("Probes a name whose gpt- token is not boundary-aligned (preceded by 'm' in customgpt).") },
|
||||
Q::Section { group: "DBv2 gpt-segment boundary probes", note: Some("Probes whether 'gpt' is treated as a full segment rather than a segment prefix.") },
|
||||
Q::Vector { id: "dbv2-gptoss-segment-probe", provider: "databricks_v2", raw_model_id: "gptoss-model", note: Some("Probes a segment ('gptoss') that starts with but is not exactly 'gpt'/'gpt5'.") },
|
||||
Q::Vector { id: "dbv2-gptj-6b-segment-probe", provider: "databricks_v2", raw_model_id: "gptj-6b", note: Some("Probes a segment ('gptj') that is not exactly 'gpt'/'gpt5'.") },
|
||||
Q::Vector { id: "dbv2-customgpt-nonboundary-probe", provider: "databricks_v2", raw_model_id: "customgpt-5-5-endpoint", note: Some("Probes a name whose gpt- token is not boundary-aligned (preceded by 'm' in customgpt).") },
|
||||
Q::Vector { id: "dbv2-gpt-neox-version-segment-probe", provider: "databricks_v2", raw_model_id: "gpt-neox-20b", note: Some("Probes a gpt- name whose next segment ('neox') is non-numeric.") },
|
||||
Q::Vector { id: "dbv2-gpt5-custom-segment-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt5-custom", note: Some("Probes a 'gpt5' segment inside a databricks- prefixed name.") },
|
||||
Q::Vector { id: "dbv2-gpt-opus-5-dual-marker-probe", provider: "databricks_v2", raw_model_id: "gpt-opus-5", note: Some("Probes a name carrying both a gpt marker and a claude code word.") },
|
||||
Q::Section { group: "Additional coverage probes", note: None },
|
||||
Q::Vector { id: "anthropic-opus-5-prefix-probe", provider: "anthropic", raw_model_id: "claude-opus-5-20270101", note: Some("Probes the claude-opus-5 prefix rule.") },
|
||||
Q::Vector { id: "dbv2-gpt-5-6-sol-normalization-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-6-sol", note: Some("Probes the sol exact record's normalization and effort axes.") },
|
||||
Q::Vector { id: "dbv2-gpt-5-6-luna-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-6-luna", note: Some("Probes the luna exact record against its family rule.") },
|
||||
Q::Vector { id: "dbv2-gpt-5-6-terra-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-6-terra", note: Some("Probes the terra exact record against its family rule.") },
|
||||
Q::Vector { id: "dbv2-gpt-5-4-nano-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-4-nano", note: Some("Probes the gpt-5-4-nano exact record and its label.") },
|
||||
Q::Vector { id: "openrouter-concrete-unknown-probe", provider: "openrouter", raw_model_id: "some-model-xyz", note: Some("Probes an uncatalogued openrouter model id.") },
|
||||
Q::Vector { id: "openai-gpt5-pro-uppercase-provider-probe", provider: "OpenAI", raw_model_id: "gpt-5-pro", note: Some("Probes an uppercased provider string ('OpenAI').") },
|
||||
Q::Vector { id: "dbv2-uppercase-model-probe", provider: "databricks_v2", raw_model_id: "DATABRICKS-GPT-5-4-NANO", note: Some("Probes an uppercased raw model id against a lowercase exact record.") },
|
||||
Q::Section { group: "Prototype-key provider probes", note: Some("Probes provider strings that collide with Object prototype keys.") },
|
||||
Q::Vector { id: "prototype-key-constructor-blank-probe", provider: "constructor", raw_model_id: "", note: None },
|
||||
Q::Vector { id: "prototype-key-constructor-some-model-probe", provider: "constructor", raw_model_id: "some-model", note: None },
|
||||
Q::Vector { id: "prototype-key-proto__-blank-probe", provider: "__proto__", raw_model_id: "", note: None },
|
||||
Q::Vector { id: "prototype-key-proto__-some-model-probe", provider: "__proto__", raw_model_id: "some-model", note: None },
|
||||
Q::Section { group: "Non-boundary gpt- prefix probes", note: Some("Probes names whose gpt- token is not boundary-aligned (preceded by an alphanumeric).") },
|
||||
Q::Vector { id: "openai-sgpt-5-5-nonboundary-probe", provider: "openai", raw_model_id: "sgpt-5-5", note: None },
|
||||
Q::Vector { id: "dbv2-sgpt-5-5-nonboundary-probe", provider: "databricks_v2", raw_model_id: "sgpt-5-5", note: None },
|
||||
Q::Vector { id: "openai-mygpt-5-nonboundary-probe", provider: "openai", raw_model_id: "mygpt-5", note: None },
|
||||
Q::Vector { id: "dbv2-mygpt-5-nonboundary-probe", provider: "databricks_v2", raw_model_id: "mygpt-5", note: None },
|
||||
Q::Vector { id: "dbv2-gpt-5-mini-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-mini", note: Some("Probes the gpt-5-mini exact record and its label.") },
|
||||
Q::Vector { id: "dbv2-gpt-5-nano-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-nano", note: Some("Probes the gpt-5-nano exact record and its label.") },
|
||||
Q::Vector { id: "dbv2-claude-opus-5-custom-family-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-5-custom", note: Some("Probes a family-matched name with no exact record and its label axis.") },
|
||||
Q::Vector { id: "dbv2-gpt-doubled-separator-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt--5", note: Some("Probes a doubled separator between gpt and its version.") },
|
||||
Q::Section { group: "gpt-5 prefix collision probes (longest-prefix + boundary)", note: None },
|
||||
Q::Vector { id: "collision-gpt-5-base-probe", provider: "openai", raw_model_id: "gpt-5", note: Some("Probes the base gpt-5 stem alone.") },
|
||||
Q::Vector { id: "collision-gpt-5-pro-probe", provider: "openai", raw_model_id: "gpt-5-pro", note: Some("Probes gpt-5-pro against the shorter gpt-5 stem.") },
|
||||
Q::Vector { id: "collision-gpt-5-10-probe", provider: "openai", raw_model_id: "gpt-5-10", note: Some("Probes a two-digit minor version against the gpt-5 stem.") },
|
||||
Q::Vector { id: "collision-gpt-5-6-probe", provider: "openai", raw_model_id: "gpt-5.6", note: Some("Probes a dotted minor version against the gpt-5 stem.") },
|
||||
Q::Vector { id: "collision-gpt-5-1-probe", provider: "openai", raw_model_id: "gpt-5.1", note: Some("Probes the gpt-5.1 prefix.") },
|
||||
Q::Section { group: "Uncurated DBv2 token probes", note: None },
|
||||
Q::Vector { id: "uncurated-dbv2-gpt-6-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-6", note: Some("Probes a non-5 gpt version with no exact record or prefix rule.") },
|
||||
Q::Vector { id: "uncurated-dbv2-gpt-4o-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-4o", note: Some("Probes an uncatalogued gpt-4o databricks_v2 id.") },
|
||||
Q::Vector { id: "uncurated-dbv2-opus-5-bare-probe", provider: "databricks_v2", raw_model_id: "opus-5", note: Some("Probes a bare Claude code-name segment with no leading claude.") },
|
||||
Q::Vector { id: "uncurated-dbv2-sol-bare-probe", provider: "databricks_v2", raw_model_id: "sol", note: Some("Probes a bare OpenAI code name.") },
|
||||
Q::Vector { id: "uncurated-dbv2-claude-prefix-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-experimental", note: Some("Probes an uncurated databricks-claude-* name.") },
|
||||
Q::Section { group: "Negative-match probes (no family rule expected to bind)", note: None },
|
||||
Q::Vector { id: "neg-gptoss-openai-probe", provider: "openai", raw_model_id: "gptoss", note: Some("Probes a name with no gpt- boundary token.") },
|
||||
Q::Vector { id: "neg-gptj-6b-openai-probe", provider: "openai", raw_model_id: "gptj-6b", note: Some("Probes 'gptj', which is not a gpt- token.") },
|
||||
Q::Vector { id: "neg-consolidated-llama-dbv2-probe", provider: "databricks_v2", raw_model_id: "consolidated-llama", note: Some("Probes a name where 'sol' is a substring, not a segment.") },
|
||||
Q::Vector { id: "neg-terraform-coder-dbv2-probe", provider: "databricks_v2", raw_model_id: "terraform-coder", note: Some("Probes a name where 'terra' is a substring, not a segment.") },
|
||||
Q::Vector { id: "neg-octopus-model-dbv2-probe", provider: "databricks_v2", raw_model_id: "octopus-model", note: Some("Probes a name where 'opus' is a substring, not a leading claude prefix.") },
|
||||
Q::Section { group: "Exact+prefix matcher boundary probes", note: None },
|
||||
Q::Vector { id: "boundary-embedded-token-openai-probe", provider: "openai", raw_model_id: "gpt-4-gpt-5-pro", note: Some("Probes a gpt-5-pro token embedded mid-name rather than at the start.") },
|
||||
Q::Vector { id: "boundary-dot-suffix-openai-probe", provider: "openai", raw_model_id: "gpt-5.6.x", note: Some("Probes a trailing dot-delimited segment after gpt-5.6.") },
|
||||
Q::Vector { id: "boundary-claude-3-digit-run-anthropic-probe", provider: "anthropic", raw_model_id: "claude-35", note: Some("Probes whether the claude-3 prefix binds a longer digit run ('35').") },
|
||||
Q::Vector { id: "boundary-claude-opus-4-70-anthropic-probe", provider: "anthropic", raw_model_id: "claude-opus-4-70", note: Some("Probes whether the claude-opus-4-7 prefix binds a longer digit run ('70').") },
|
||||
Q::Vector { id: "boundary-gpt-5-1234-openai-probe", provider: "openai", raw_model_id: "gpt-5-1234", note: Some("Probes a 4-digit run after the gpt-5 stem.") },
|
||||
];
|
||||
|
||||
/// A section marker in the generated corpus (`_group` + optional `_note`).
|
||||
#[derive(Serialize)]
|
||||
struct SectionOut {
|
||||
#[serde(rename = "_group")]
|
||||
group: &'static str,
|
||||
#[serde(rename = "_note", skip_serializing_if = "Option::is_none")]
|
||||
note: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// One executable vector: the query, an optional note, and the resolver's
|
||||
/// snapshotted answer. `expect` is a [`CapabilityResult`] serialized
|
||||
/// directly — the axis names/order and the enum spellings come from the
|
||||
/// production types, so nothing about the answer is encoded a second time.
|
||||
#[derive(Serialize)]
|
||||
struct VectorOut {
|
||||
id: &'static str,
|
||||
provider: &'static str,
|
||||
raw_model_id: &'static str,
|
||||
#[serde(rename = "_note", skip_serializing_if = "Option::is_none")]
|
||||
note: Option<&'static str>,
|
||||
expect: CapabilityResult,
|
||||
}
|
||||
|
||||
/// A heterogeneous corpus entry. `untagged` writes the inner object with no
|
||||
/// discriminator, yielding the one flat array the harnesses replay.
|
||||
#[derive(Serialize)]
|
||||
#[serde(untagged)]
|
||||
enum CorpusOut {
|
||||
Section(SectionOut),
|
||||
Vector(VectorOut),
|
||||
}
|
||||
|
||||
const CORPUS_JSON: &str = include_str!("../../../scripts/normative-corpus.json");
|
||||
|
||||
/// Render the corpus from [`INPUTS`] by running the production [`resolve`]
|
||||
/// over every query. Deterministic: fixed input order, struct-declaration
|
||||
/// key order, `serde_json` pretty (2-space) formatting, trailing newline.
|
||||
/// This is the single writer used by both the drift gate and the regen
|
||||
/// recipe, so "what the gate checks" and "what regen writes" cannot drift.
|
||||
fn generate_corpus_json() -> String {
|
||||
let entries: Vec<CorpusOut> = INPUTS
|
||||
.iter()
|
||||
.map(|q| match *q {
|
||||
Q::Section { group, note } => CorpusOut::Section(SectionOut { group, note }),
|
||||
Q::Vector {
|
||||
id,
|
||||
provider,
|
||||
raw_model_id,
|
||||
note,
|
||||
} => CorpusOut::Vector(VectorOut {
|
||||
id,
|
||||
provider,
|
||||
raw_model_id,
|
||||
note,
|
||||
expect: resolve(provider, raw_model_id),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
let mut json = serde_json::to_string_pretty(&entries)
|
||||
.expect("corpus entries serialize as pretty JSON");
|
||||
json.push('\n');
|
||||
json
|
||||
}
|
||||
|
||||
/// Absolute path of the committed corpus, from the crate root at compile
|
||||
/// time — the same file [`CORPUS_JSON`] embeds, so the regen recipe writes
|
||||
/// exactly what the drift gate reads.
|
||||
fn corpus_path() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../scripts/normative-corpus.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_manifest_parses_and_validates() {
|
||||
// Exercises the include_str! + strict serde + validate_manifest chain.
|
||||
let _ = manifest();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_matches_generated_snapshot() {
|
||||
// Drift gate: the committed corpus must be byte-identical to what the
|
||||
// production resolver generates right now. A byte match proves every
|
||||
// `expect` in the file is the resolver's current answer — the same
|
||||
// cross-language contract the old hand-maintained corpus enforced,
|
||||
// now impossible to hand-edit out of sync. `just regen-model-corpus`
|
||||
// rewrites the file from this exact generator.
|
||||
assert_eq!(
|
||||
CORPUS_JSON,
|
||||
generate_corpus_json(),
|
||||
"scripts/normative-corpus.json is out of date — run `just regen-model-corpus` and commit the result"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_has_exactly_103_executable_vectors() {
|
||||
// Locks the vector count so a silent INPUTS edit can't quietly drop
|
||||
// coverage; must equal the gate in the TS harness
|
||||
// (modelCapabilitiesCorpus.test.mjs).
|
||||
let vectors = INPUTS
|
||||
.iter()
|
||||
.filter(|q| matches!(q, Q::Vector { .. }))
|
||||
.count();
|
||||
assert_eq!(
|
||||
vectors, 103,
|
||||
"corpus executable-vector count changed; update this gate deliberately"
|
||||
);
|
||||
}
|
||||
|
||||
/// Rewrite `scripts/normative-corpus.json` from the production resolver.
|
||||
/// `#[ignore]` so the ordinary test run only *checks* the committed bytes
|
||||
/// (via `corpus_matches_generated_snapshot`); this is the writer half,
|
||||
/// invoked by `just regen-model-corpus`.
|
||||
#[test]
|
||||
#[ignore = "writer, not a check — run via `just regen-model-corpus`"]
|
||||
fn regen_corpus_file() {
|
||||
std::fs::write(corpus_path(), generate_corpus_json())
|
||||
.expect("write scripts/normative-corpus.json");
|
||||
}
|
||||
|
||||
// --- Migrated relational/invariant tests (see 42-test inventory) ---
|
||||
// These assert cross-input properties a single corpus vector cannot express.
|
||||
|
||||
#[test]
|
||||
fn test_gpt5_numeric_date_suffix_matches_base_not_version() {
|
||||
// A 4-digit date-like suffix on a non-boundary must fall to the gpt-5 base,
|
||||
// never to the gpt-5.1 version rule.
|
||||
let base = resolve("openai", "gpt-5");
|
||||
for id in ["gpt-5-1106", "gpt-5-20260101"] {
|
||||
assert_eq!(
|
||||
resolve("openai", id).supported_efforts,
|
||||
base.supported_efforts,
|
||||
"{id} must match gpt-5 base efforts"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gpt5_lettered_suffix_matches_base_not_gpt5_4() {
|
||||
// `gpt-5-4o` has an alnum char after `gpt-5-4`, so the gpt-5.4 rule must
|
||||
// not match; it falls to the base rule.
|
||||
let base = resolve("openai", "gpt-5");
|
||||
let gpt5_4 = resolve("openai", "gpt-5.4");
|
||||
let got = resolve("openai", "gpt-5-4o");
|
||||
assert_eq!(got.supported_efforts, base.supported_efforts);
|
||||
assert_ne!(got.supported_efforts, gpt5_4.supported_efforts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gpt5_pro_wins_over_base_by_longest_prefix() {
|
||||
// `gpt-5-pro` matches both the base (`gpt-5`) and the pro rule; longest
|
||||
// prefix must select pro (high-only).
|
||||
let pro = resolve("openai", "gpt-5-pro");
|
||||
let base = resolve("openai", "gpt-5");
|
||||
assert_eq!(pro.supported_efforts, &[ThinkingEffort::High]);
|
||||
assert_ne!(pro.supported_efforts, base.supported_efforts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_every_resolve_yields_a_complete_result() {
|
||||
// Complete-result invariant: supported_efforts is never empty on any path.
|
||||
let inputs = [
|
||||
("anthropic", "claude-opus-4-7"),
|
||||
("anthropic", ""),
|
||||
("anthropic", "claude-ultra-9000"),
|
||||
("openai", "gpt-5"),
|
||||
("openai", ""),
|
||||
("openai", "gpt-4o"),
|
||||
("databricks_v2", "databricks-gpt-5-4-mini"),
|
||||
("databricks_v2", ""),
|
||||
("databricks_v2", "some-unknown-xyz"),
|
||||
("databricks", "databricks-gpt-5-pro"),
|
||||
("openrouter", "whatever"),
|
||||
("openai-compat", "gpt-5.5"),
|
||||
("__proto__", ""),
|
||||
("constructor", "some-model"),
|
||||
("", ""),
|
||||
("totally-unknown", "totally-unknown"),
|
||||
];
|
||||
for (provider, model) in inputs {
|
||||
let got = resolve(provider, model);
|
||||
assert!(
|
||||
!got.supported_efforts.is_empty(),
|
||||
"resolve({provider:?}, {model:?}) returned empty supported_efforts"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- New direct-resolver tests (contract 5) ---
|
||||
|
||||
#[test]
|
||||
fn test_whitespace_only_model_id_uses_blank_fallback() {
|
||||
// A whitespace-only id trims to blank and takes the blank fallback, which
|
||||
// differs from the concrete-unknown fallback for databricks_v2 (route).
|
||||
let ws = resolve("databricks_v2", " ");
|
||||
let blank = resolve("databricks_v2", "");
|
||||
assert_eq!(ws, blank);
|
||||
assert_eq!(ws.databricks_v2_wire_route, DatabricksV2Route::RouteUnknown);
|
||||
let concrete = resolve("databricks_v2", "some-unknown-xyz");
|
||||
assert_eq!(
|
||||
concrete.databricks_v2_wire_route,
|
||||
DatabricksV2Route::MlflowChat
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefix_tie_break_is_lexicographic_on_rule_id() {
|
||||
// gpt-5.1 matches the gpt-5.1 rule's exact value (len 7) over the base
|
||||
// prefix (len 5); the longest-match + tie-break path is deterministic.
|
||||
let a = resolve("openai", "gpt-5.1");
|
||||
let b = resolve("openai", "gpt-5.1");
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.default_effort, Some(ThinkingEffort::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exact_record_beats_family_prefix() {
|
||||
// databricks-gpt-5-4-mini has an exact record (label present); the family
|
||||
// prefix would otherwise apply and carry no label.
|
||||
let got = resolve("databricks_v2", "databricks-gpt-5-4-mini");
|
||||
assert_eq!(got.registry_label, Some("GPT-5.4 mini"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_known_models_accessor_reads_manifest() {
|
||||
let known = databricks_v2_known_models();
|
||||
assert!(known.iter().any(|m| m == "databricks-gpt-5-5"));
|
||||
assert!(known.iter().any(|m| m == "databricks-claude-opus-4-7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_databricks_registry_label_lookup() {
|
||||
// Known id → curated label; case-insensitive on the id, matching resolve().
|
||||
assert_eq!(
|
||||
databricks_registry_label("databricks-gpt-5-5"),
|
||||
Some("GPT-5.5")
|
||||
);
|
||||
assert_eq!(
|
||||
databricks_registry_label("DATABRICKS-GPT-5-5"),
|
||||
Some("GPT-5.5")
|
||||
);
|
||||
// Unknown id and blank input → no label.
|
||||
assert_eq!(databricks_registry_label("custom-unlisted-endpoint"), None);
|
||||
assert_eq!(databricks_registry_label(" "), None);
|
||||
}
|
||||
}
|
||||
5
desktop/src/features-manifest.d.ts
vendored
5
desktop/src/features-manifest.d.ts
vendored
@@ -2,3 +2,8 @@ declare module "@features-manifest" {
|
||||
const manifest: import("@/shared/features/types").FeaturesManifest;
|
||||
export default manifest;
|
||||
}
|
||||
|
||||
declare module "@model-capabilities-manifest" {
|
||||
const manifest: unknown;
|
||||
export default manifest;
|
||||
}
|
||||
|
||||
@@ -56,3 +56,140 @@ test("resolveAgentCardModelLabel — non-inherited agent with a blank resolved m
|
||||
});
|
||||
assert.equal(label, "Default model (claude-sonnet)");
|
||||
});
|
||||
|
||||
// Databricks registry integration
|
||||
import { formatAgentModelLabel } from "./formatAgentModelLabel.ts";
|
||||
|
||||
test("formatAgentModelLabel — known Databricks managed ID returns curated name", () => {
|
||||
assert.equal(formatAgentModelLabel("databricks-gpt-5-5"), "GPT-5.5");
|
||||
assert.equal(
|
||||
formatAgentModelLabel("databricks-claude-opus-4-7"),
|
||||
"Claude Opus 4.7",
|
||||
);
|
||||
});
|
||||
|
||||
test("formatAgentModelLabel — unknown custom Databricks ID returns raw ID unchanged", () => {
|
||||
assert.equal(
|
||||
formatAgentModelLabel("databricks-team-2025-01"),
|
||||
"databricks-team-2025-01",
|
||||
);
|
||||
});
|
||||
|
||||
test("formatAgentModelLabel — non-Databricks ID returns raw ID unchanged", () => {
|
||||
assert.equal(formatAgentModelLabel("claude-sonnet-4-7"), "claude-sonnet-4-7");
|
||||
assert.equal(formatAgentModelLabel("gpt-4o"), "gpt-4o");
|
||||
});
|
||||
|
||||
test("formatAgentModelLabel — null or empty returns Auto", () => {
|
||||
assert.equal(formatAgentModelLabel(null), "Auto");
|
||||
assert.equal(formatAgentModelLabel(""), "Auto");
|
||||
assert.equal(formatAgentModelLabel(" "), "Auto");
|
||||
});
|
||||
|
||||
import { resolveModelLabel } from "./formatAgentModelLabel.ts";
|
||||
|
||||
test("resolveModelLabel — echoed id name falls through to the registry (real discovery shape)", () => {
|
||||
// buzz-agent's Databricks discovery emits {id, name: id}; the echoed name
|
||||
// carries no display info, so the registry tier must curate the label.
|
||||
assert.equal(
|
||||
resolveModelLabel(
|
||||
"databricks-gpt-5-5",
|
||||
"databricks-gpt-5-5",
|
||||
"databricks_v2",
|
||||
),
|
||||
"GPT-5.5",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveModelLabel — echoed id name for an unknown id stays raw", () => {
|
||||
assert.equal(
|
||||
resolveModelLabel(
|
||||
"databricks-team-2025-01",
|
||||
"databricks-team-2025-01",
|
||||
"databricks_v2",
|
||||
),
|
||||
"databricks-team-2025-01",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveModelLabel — a discovered name distinct from the id still wins tier 1", () => {
|
||||
// The "(default catalog)" suffixed name (and any genuinely distinct name) is
|
||||
// authoritative and must not be discarded by the echo-equality check.
|
||||
assert.equal(
|
||||
resolveModelLabel(
|
||||
"databricks-gpt-5-5",
|
||||
"GPT-5.5 (default catalog)",
|
||||
"databricks_v2",
|
||||
),
|
||||
"GPT-5.5 (default catalog)",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveAgentCardModelLabel — known Databricks defaultModel with databricks_v2 provider renders curated name in default label", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: undefined,
|
||||
personaModel: null,
|
||||
provider: "databricks_v2",
|
||||
defaultModel: "databricks-gpt-5-5",
|
||||
});
|
||||
assert.equal(label, "Default model (GPT-5.5)");
|
||||
});
|
||||
|
||||
test("resolveAgentCardModelLabel — unknown custom Databricks defaultModel renders raw ID in default label", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: undefined,
|
||||
personaModel: null,
|
||||
defaultModel: "databricks-team-2025-01",
|
||||
});
|
||||
assert.equal(label, "Default model (databricks-team-2025-01)");
|
||||
});
|
||||
|
||||
test("resolveAgentCardModelLabel — known Databricks agent model renders curated name", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: { modelSource: "definition", model: "databricks-gpt-oss-120b" },
|
||||
personaModel: null,
|
||||
defaultModel: "something-else",
|
||||
});
|
||||
assert.equal(label, "GPT OSS 120B");
|
||||
});
|
||||
|
||||
// P2 regression: provider-scoped default label — Databricks ID under openai/anthropic must render raw
|
||||
test("resolveAgentCardModelLabel — openai agent inheriting a Databricks-named default renders raw ID", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: { modelSource: "global", model: null, provider: "openai" },
|
||||
personaModel: null,
|
||||
provider: "openai",
|
||||
defaultModel: "databricks-gpt-5-5",
|
||||
});
|
||||
assert.equal(label, "Default model (databricks-gpt-5-5)");
|
||||
});
|
||||
|
||||
test("resolveAgentCardModelLabel — anthropic agent inheriting a Databricks-named default renders raw ID", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: { modelSource: "global", model: null, provider: "anthropic" },
|
||||
personaModel: null,
|
||||
provider: "anthropic",
|
||||
defaultModel: "databricks-gpt-5-5",
|
||||
});
|
||||
assert.equal(label, "Default model (databricks-gpt-5-5)");
|
||||
});
|
||||
|
||||
test("resolveAgentCardModelLabel — databricks_v2 agent inheriting a Databricks-named default renders curated name", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: { modelSource: "global", model: null, provider: "databricks_v2" },
|
||||
personaModel: null,
|
||||
provider: "databricks_v2",
|
||||
defaultModel: "databricks-gpt-5-5",
|
||||
});
|
||||
assert.equal(label, "Default model (GPT-5.5)");
|
||||
});
|
||||
|
||||
test("resolveAgentCardModelLabel — unspawned openai persona with Databricks-named default renders raw ID", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: undefined,
|
||||
personaModel: null,
|
||||
provider: "openai",
|
||||
defaultModel: "databricks-gpt-5-5",
|
||||
});
|
||||
assert.equal(label, "Default model (databricks-gpt-5-5)");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { formatAgentModelLabel } from "./formatAgentModelLabel";
|
||||
import {
|
||||
formatAgentModelLabel,
|
||||
resolveModelLabel,
|
||||
} from "./formatAgentModelLabel";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
|
||||
/**
|
||||
@@ -19,26 +22,33 @@ import type { ManagedAgent } from "@/shared/api/types";
|
||||
* than falling through to "inherited" for lack of an instance.
|
||||
*/
|
||||
export function resolveAgentCardModelLabel(input: {
|
||||
agent: Pick<ManagedAgent, "modelSource" | "model"> | undefined;
|
||||
agent: Pick<ManagedAgent, "modelSource" | "model" | "provider"> | undefined;
|
||||
personaModel: string | null | undefined;
|
||||
/** Inference provider for the persona/agent — threads provider-qualified label lookup. */
|
||||
provider?: string | null | undefined;
|
||||
defaultModel: string;
|
||||
}): string {
|
||||
if (input.agent) {
|
||||
const isInherited =
|
||||
!input.agent.modelSource || input.agent.modelSource === "global";
|
||||
if (isInherited) {
|
||||
return formatDefaultModelLabel(input.defaultModel);
|
||||
return formatDefaultModelLabel(input.defaultModel, input.agent.provider);
|
||||
}
|
||||
return input.agent.model?.trim()
|
||||
? formatAgentModelLabel(input.agent.model)
|
||||
: formatDefaultModelLabel(input.defaultModel);
|
||||
? formatAgentModelLabel(input.agent.model, input.agent.provider)
|
||||
: formatDefaultModelLabel(input.defaultModel, input.agent.provider);
|
||||
}
|
||||
return input.personaModel?.trim()
|
||||
? formatAgentModelLabel(input.personaModel)
|
||||
: formatDefaultModelLabel(input.defaultModel);
|
||||
? formatAgentModelLabel(input.personaModel, input.provider)
|
||||
: formatDefaultModelLabel(input.defaultModel, input.provider);
|
||||
}
|
||||
|
||||
export function formatDefaultModelLabel(defaultModel: string) {
|
||||
export function formatDefaultModelLabel(
|
||||
defaultModel: string,
|
||||
provider?: string | null | undefined,
|
||||
) {
|
||||
const model = defaultModel.trim();
|
||||
return model ? `Default model (${model})` : "Default model";
|
||||
return model
|
||||
? `Default model (${resolveModelLabel(model, undefined, provider)})`
|
||||
: "Default model";
|
||||
}
|
||||
|
||||
@@ -1,8 +1,76 @@
|
||||
import {
|
||||
canonicalizeProvider,
|
||||
DATABRICKS_MODEL_NAMES,
|
||||
resolveModelCapabilities,
|
||||
} from "../ui/modelCapabilities";
|
||||
|
||||
// Re-exported so the label surface remains the single import site for provider
|
||||
// canonicalization; the interpreter owns the alias map.
|
||||
export { canonicalizeProvider };
|
||||
|
||||
/**
|
||||
* Resolves a human-readable label for a model, following a three-tier
|
||||
* precedence:
|
||||
*
|
||||
* 1. Non-blank discovered/API name (e.g. from `AgentModelInfo.name`) that is
|
||||
* genuinely distinct from the id. A discovered name that merely echoes the
|
||||
* trimmed id carries no display information, so it is treated as absent and
|
||||
* falls through to the registry tier — this covers buzz-agent's Databricks
|
||||
* discovery contract (`{id, name: id}`) and any harness/version skew that
|
||||
* echoes the id as the name.
|
||||
* 2. Registry lookup by id:
|
||||
* - `provider` supplied → provider-qualified exact record only. On a miss
|
||||
* the raw id is returned; the unscoped `DATABRICKS_MODEL_NAMES` map is
|
||||
* NOT consulted, so a Databricks endpoint id never leaks a curated label
|
||||
* through an anthropic/openai provider context (the P3-B contract).
|
||||
* - `provider` absent → unscoped `DATABRICKS_MODEL_NAMES` map, for
|
||||
* legacy/inherited ids with no provider on hand.
|
||||
* 3. Raw id unchanged.
|
||||
*
|
||||
* Returns the empty string when both id and discoveredName are blank; use
|
||||
* `formatAgentModelLabel` when a null/empty id should render "Auto".
|
||||
*
|
||||
* `resolveModelCapabilities` canonicalizes the provider internally, so callers
|
||||
* pass the raw provider id. Only exact records carry a `registryLabel`, so a
|
||||
* family/prefix hit yields `null` and correctly falls back to the raw id.
|
||||
*/
|
||||
export function resolveModelLabel(
|
||||
id: string,
|
||||
discoveredName?: string | null | undefined,
|
||||
provider?: string | null | undefined,
|
||||
): string {
|
||||
const trimmedName = discoveredName?.trim();
|
||||
const trimmedId = id.trim();
|
||||
// A discovered name distinct from the id is authoritative (tier 1). A name
|
||||
// that merely echoes the id is treated as absent so the registry tier runs.
|
||||
if (trimmedName && trimmedName !== trimmedId) return trimmedName;
|
||||
if (!trimmedId) return "";
|
||||
if (provider?.trim()) {
|
||||
// Provider-qualified exact-record tier (provider-scoped, no unscoped fallback).
|
||||
const registryLabel = resolveModelCapabilities(
|
||||
provider,
|
||||
trimmedId,
|
||||
).registryLabel;
|
||||
return registryLabel ?? trimmedId;
|
||||
}
|
||||
// Providerless path: unscoped registry map for legacy/inherited ids.
|
||||
return DATABRICKS_MODEL_NAMES.get(trimmedId) ?? trimmedId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable model label for an agent or persona, falling back to
|
||||
* "Auto" when no model is set (empty or whitespace-only).
|
||||
*
|
||||
* For known Databricks managed endpoints the registry-curated name is returned
|
||||
* (e.g. "databricks-gpt-5-5" → "GPT-5.5"). Unknown or custom endpoint ids are
|
||||
* returned unchanged — no heuristic string mangling. Pass `provider` when the
|
||||
* inference provider is known to get a provider-qualified registry label.
|
||||
*/
|
||||
export function formatAgentModelLabel(model: string | null | undefined) {
|
||||
export function formatAgentModelLabel(
|
||||
model: string | null | undefined,
|
||||
provider?: string | null | undefined,
|
||||
) {
|
||||
const trimmed = model?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : "Auto";
|
||||
if (!trimmed) return "Auto";
|
||||
return resolveModelLabel(trimmed, null, provider);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from "@/features/agents/ui/agentConfigControls";
|
||||
import { PersonaProviderApiKeyField } from "@/features/agents/ui/PersonaProviderApiKeyField";
|
||||
import { usePersonaModelDiscovery } from "@/features/agents/ui/usePersonaModelDiscovery";
|
||||
import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
|
||||
import {
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
getProviderEffortConfig,
|
||||
@@ -799,7 +800,9 @@ export function AgentConfigFields({
|
||||
<AgentModelField
|
||||
allowDefaultModel={fallbackModel !== null}
|
||||
defaultModelLabel={
|
||||
fallbackModel ? `Default model (${fallbackModel})` : undefined
|
||||
fallbackModel
|
||||
? `Default model (${resolveModelLabel(fallbackModel, undefined, effectiveProvider || undefined)})`
|
||||
: undefined
|
||||
}
|
||||
disableSelectDuringDiscovery={disableModelSelectDuringDiscovery}
|
||||
disabled={dependentFieldsDisabled}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastE
|
||||
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
|
||||
import { PubKey } from "@/shared/ui/PubKey";
|
||||
import { SubsectionLabel } from "@/shared/ui/PageHeader";
|
||||
import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
|
||||
import { RestartDiffBadge } from "./RestartDiffBadge";
|
||||
|
||||
export function ManagedAgentRow({
|
||||
@@ -410,7 +411,9 @@ function RuntimeBlock({
|
||||
{runtimeSource || agent.model ? (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{runtimeSource ? <span>{runtimeSource}</span> : null}
|
||||
{agent.model ? <span>{agent.model}</span> : null}
|
||||
{agent.model ? (
|
||||
<span>{resolveModelLabel(agent.model, null, agent.provider)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
|
||||
|
||||
export function ModelPicker({
|
||||
agent,
|
||||
@@ -82,13 +83,13 @@ export function ModelPicker({
|
||||
);
|
||||
|
||||
const currentValue = agent.model ?? modelsData?.agentDefaultModel ?? "";
|
||||
const displayLabel =
|
||||
agent.model ??
|
||||
(modelsData?.agentDefaultModel
|
||||
? `${modelsData.agentDefaultModel} (default)`
|
||||
const displayLabel = agent.model
|
||||
? resolveModelLabel(agent.model, null, agent.provider)
|
||||
: modelsData?.agentDefaultModel
|
||||
? `${resolveModelLabel(modelsData.agentDefaultModel, null, agent.provider)} (default)`
|
||||
: hasRequestedModels && loading
|
||||
? "Loading..."
|
||||
: "Auto");
|
||||
: "Auto";
|
||||
|
||||
// Provenance label shown only for post-spawn agents where the model origin
|
||||
// is known from the config surface and the source is not a user-explicit
|
||||
@@ -221,7 +222,9 @@ export function ModelPicker({
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{agent.model ? (
|
||||
<>
|
||||
<p className="font-medium text-foreground">{agent.model}</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{resolveModelLabel(agent.model, null, agent.provider)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs">
|
||||
This runtime does not support switching models.
|
||||
</p>
|
||||
@@ -237,7 +240,7 @@ export function ModelPicker({
|
||||
>
|
||||
{modelsData.models.map((model) => (
|
||||
<DropdownMenuRadioItem key={model.id} value={model.id}>
|
||||
{model.name ?? model.id}
|
||||
{resolveModelLabel(model.id, model.name, agent.provider)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
@@ -203,7 +203,7 @@ function TeamAvatarItem({
|
||||
|
||||
function getTeamFooterModelLabel(personas: AgentPersona[]) {
|
||||
const modelLabels = personas
|
||||
.map((persona) => formatAgentModelLabel(persona.model))
|
||||
.map((persona) => formatAgentModelLabel(persona.model, persona.provider))
|
||||
.filter((model): model is string => Boolean(model));
|
||||
|
||||
if (modelLabels.length === 0) return "Auto";
|
||||
|
||||
@@ -254,6 +254,7 @@ function AgentPersonaCard({
|
||||
const modelLabel = resolveAgentCardModelLabel({
|
||||
agent,
|
||||
personaModel: persona.model,
|
||||
provider: persona.provider,
|
||||
defaultModel,
|
||||
});
|
||||
const isActive = agent ? isManagedAgentActive(agent) : false;
|
||||
@@ -393,6 +394,7 @@ function StandaloneAgentCard({
|
||||
modelLabel={resolveAgentCardModelLabel({
|
||||
agent,
|
||||
personaModel: null,
|
||||
provider: agent.provider,
|
||||
defaultModel,
|
||||
})}
|
||||
onClick={() => {
|
||||
|
||||
@@ -395,14 +395,15 @@ test("openai gpt-5-pro is not matched by gpt-5 base bucket", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// gpt-5.10 must NOT match gpt-5.1 (digit boundary)
|
||||
test("openai gpt-5.10 is not matched by gpt-5.1 token", () => {
|
||||
const { validValues } = getProviderEffortConfig("openai", "gpt-5.10");
|
||||
// gpt-5.10 doesn't match any specific family → falls into unknown table
|
||||
assert.deepEqual(
|
||||
[...validValues],
|
||||
["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
// gpt-5.10 must NOT match gpt-5.1 (digit boundary), but DOES match the gpt-5
|
||||
// base rule at the dot boundary → base table, not the unknown fallback.
|
||||
test("openai gpt-5.10 rejects gpt-5.1 at the digit boundary and matches the gpt-5 base rule", () => {
|
||||
const { validValues, defaultValue } = getProviderEffortConfig(
|
||||
"openai",
|
||||
"gpt-5.10",
|
||||
);
|
||||
assert.deepEqual([...validValues], ["minimal", "low", "medium", "high"]);
|
||||
assert.equal(defaultValue, "medium");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -449,8 +450,9 @@ test("databricks_v2 with databricks-gpt-5.1 strips prefix and routes to OpenAI g
|
||||
});
|
||||
|
||||
test("databricks_v2 with concrete non-claude non-gpt model excludes max (MLflow clamps it)", () => {
|
||||
// llama-3 routes through MlflowChatCompletions → normalize_effort_for_openai_route
|
||||
// → max is clamped to xhigh. Show all-except-max so the UI is honest.
|
||||
// llama-3 falls to the databricks_v2 concrete-unknown fallback, whose
|
||||
// OpenaiClampMaxToXhigh normalization policy clamps max→xhigh
|
||||
// (normalize_effort_for_databricks_v2). Show all-except-max so the UI is honest.
|
||||
const { validValues, defaultValue } = getProviderEffortConfig(
|
||||
"databricks_v2",
|
||||
"llama-3",
|
||||
@@ -541,12 +543,17 @@ test("databricks v1 routes like openai unknown (no gpt-5 model)", () => {
|
||||
assert.equal(defaultValue, "medium");
|
||||
});
|
||||
|
||||
test("openai-compat returns all-7 with medium default", () => {
|
||||
test("openai-compat returns all-except-max with medium default", () => {
|
||||
// openai-compat canonicalizes to openai, whose blank/unknown fallback omits
|
||||
// max (the OpenAI wire route clamps max → xhigh, so the UI stays honest).
|
||||
const { validValues, defaultValue } = getProviderEffortConfig(
|
||||
"openai-compat",
|
||||
"",
|
||||
);
|
||||
assert.equal(validValues.length, 7);
|
||||
assert.deepEqual(
|
||||
[...validValues],
|
||||
["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
);
|
||||
assert.equal(defaultValue, "medium");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
/**
|
||||
* Source-of-truth constants for buzz-agent model-tuning configuration knobs.
|
||||
*
|
||||
* Values must stay in sync with `crates/buzz-agent/src/config.rs`
|
||||
* `parse_thinking_effort` — that function is the authoritative list.
|
||||
* The thinking-effort value list and the provider/model → effort projection are
|
||||
* both derived from the shared capability manifest via the interpreter in
|
||||
* `./modelCapabilities`; this module owns only the buzz-agent env-var keys and
|
||||
* the runtime-id guard. Mirrors the `config.rs` ⇄ `model_capabilities.rs` seam
|
||||
* in `crates/buzz-agent`, where effort resolution is delegated to the manifest.
|
||||
* (The interpreter owns the value list rather than the reverse, because it uses
|
||||
* the values at module-load for zod — the acyclic direction.)
|
||||
*/
|
||||
import {
|
||||
THINKING_EFFORT_VALUES,
|
||||
type ThinkingEffortValue,
|
||||
resolveModelCapabilities,
|
||||
} from "./modelCapabilities";
|
||||
|
||||
/** Env var key for the thinking/effort level sent to the LLM. */
|
||||
export const BUZZ_AGENT_THINKING_EFFORT = "BUZZ_AGENT_THINKING_EFFORT";
|
||||
@@ -19,24 +29,12 @@ export const BUZZ_AGENT_MAX_ROUNDS = "BUZZ_AGENT_MAX_ROUNDS";
|
||||
|
||||
/**
|
||||
* Ordered set of valid thinking-effort values accepted by buzz-agent.
|
||||
* Mirrors `parse_thinking_effort` in `crates/buzz-agent/src/config.rs`.
|
||||
* Re-exported from the manifest interpreter, which owns the canonical list
|
||||
* (mirrors `parse_thinking_effort` in `crates/buzz-agent/src/config.rs`).
|
||||
*/
|
||||
export const BUZZ_AGENT_THINKING_EFFORT_VALUES = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
] as const;
|
||||
export const BUZZ_AGENT_THINKING_EFFORT_VALUES = THINKING_EFFORT_VALUES;
|
||||
|
||||
export type ThinkingEffortValue =
|
||||
(typeof BUZZ_AGENT_THINKING_EFFORT_VALUES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider-aware effort configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
export type { ThinkingEffortValue };
|
||||
|
||||
/**
|
||||
* Describes which thinking-effort values are valid for a given provider+model,
|
||||
@@ -44,12 +42,8 @@ export type ThinkingEffortValue =
|
||||
*
|
||||
* `defaultValue = null` means the provider/model's default is to omit the
|
||||
* thinking configuration entirely (i.e. "Inherit" is the natural default).
|
||||
* This applies to Anthropic manual-budget models where the effort level maps
|
||||
* to a budget_tokens count — there is no "default effort level" in the API.
|
||||
*
|
||||
* Mirrors the model-family tables in `crates/buzz-agent/src/config.rs`
|
||||
* (`openai_efforts_for_model`, `is_manual_budget_model`,
|
||||
* `is_adaptive_thinking_model`, `clamp_adaptive_effort`). Keep in sync.
|
||||
* This applies to Anthropic manual-budget models, whose effort maps to a
|
||||
* budget_tokens count — there is no "default effort level" in the API.
|
||||
*/
|
||||
export type ProviderEffortConfig = {
|
||||
validValues: ReadonlyArray<ThinkingEffortValue>;
|
||||
@@ -57,246 +51,23 @@ export type ProviderEffortConfig = {
|
||||
defaultValue: ThinkingEffortValue | null;
|
||||
};
|
||||
|
||||
const ALL_VALUES = BUZZ_AGENT_THINKING_EFFORT_VALUES;
|
||||
|
||||
/**
|
||||
* Returns the valid thinking-effort values and semantic default for the
|
||||
* given provider and optional model string.
|
||||
* Returns the valid thinking-effort values and semantic default for the given
|
||||
* provider and optional model, projected from the shared capability manifest.
|
||||
*
|
||||
* Model matching mirrors the Rust backend:
|
||||
* - Anthropic: strip any endpoint-naming prefix, then test `is_manual_budget_model`
|
||||
* / `is_adaptive_thinking_model` / `clamp_adaptive_effort` family checks.
|
||||
* - OpenAI: strip any endpoint-naming prefix, then test `openai_efforts_for_model`
|
||||
* family checks (boundary-aware: -pro before -5.x, digit/letter boundary).
|
||||
* - DatabricksV2: strip prefix and route by model family.
|
||||
* - Unknown/empty: all 7 values, default medium.
|
||||
*
|
||||
* Prefix stripping: finds the first occurrence of a known model-family token
|
||||
* (`claude-`, `gpt-`) and drops everything before it. This handles any
|
||||
* endpoint-naming convention (e.g. `databricks-`, `goose-`, `team-x-`) without
|
||||
* maintaining an allowlist of known prefixes. If no family token is found, the
|
||||
* raw model name is used as-is.
|
||||
* A thin projection over `resolveModelCapabilities`: `validValues` is the
|
||||
* resolved `supportedEfforts` axis and `defaultValue` is `defaultEffort`.
|
||||
* Provider canonicalization (alias map) and endpoint-prefix stripping happen
|
||||
* inside the resolver, so callers pass raw provider/model strings.
|
||||
*/
|
||||
export function getProviderEffortConfig(
|
||||
providerId: string,
|
||||
model?: string,
|
||||
): ProviderEffortConfig {
|
||||
const provider = providerId.toLowerCase();
|
||||
// Strip arbitrary endpoint-naming prefix before model-family matching.
|
||||
// Find the first occurrence of a known family token and drop everything before it.
|
||||
// e.g. "goose-claude-fable-5" → "claude-fable-5"
|
||||
// "team-x-gpt-5.5" → "gpt-5.5"
|
||||
// "databricks-claude-3" → "claude-3"
|
||||
// "claude-opus-4-7" → "claude-opus-4-7" (no prefix to strip)
|
||||
const rawModel = (model ?? "").trim().toLowerCase();
|
||||
const FAMILY_TOKENS = ["claude-", "gpt-"] as const;
|
||||
const firstFamilyIdx = Math.min(
|
||||
...FAMILY_TOKENS.map((tok) => {
|
||||
const idx = rawModel.indexOf(tok);
|
||||
return idx === -1 ? Infinity : idx;
|
||||
}),
|
||||
);
|
||||
const m =
|
||||
firstFamilyIdx === Infinity ? rawModel : rawModel.slice(firstFamilyIdx);
|
||||
|
||||
if (provider === "anthropic") {
|
||||
return anthropicConfig(m);
|
||||
}
|
||||
if (provider === "openai") {
|
||||
return openaiConfig(m);
|
||||
}
|
||||
if (provider === "databricks_v2") {
|
||||
// Route by model family: claude* → Anthropic tables, gpt-5* → OpenAI tables.
|
||||
// Non-Claude concrete models (e.g. llama-3) go through MlflowChatCompletions,
|
||||
// which applies normalize_effort_for_openai_route → clamps max to xhigh.
|
||||
// Route them through openaiConfig to exclude max. Only blank/unknown model
|
||||
// uses the all-7 fallback (can't know the route without a concrete model).
|
||||
if (m.startsWith("claude-")) {
|
||||
return anthropicConfig(m);
|
||||
}
|
||||
if (gpt5FamilyModel(m)) {
|
||||
return openaiConfig(m);
|
||||
}
|
||||
if (m.length > 0) {
|
||||
// Concrete non-Claude, non-GPT model → MLflow path clamps max → xhigh.
|
||||
return openaiConfig(m);
|
||||
}
|
||||
// Blank model — route unknown, show all 7.
|
||||
return { validValues: ALL_VALUES, defaultValue: "medium" };
|
||||
}
|
||||
if (provider === "databricks") {
|
||||
// databricks v1 uses OpenAI Chat Completions wire format.
|
||||
return openaiConfig(m);
|
||||
}
|
||||
if (provider === "openrouter") {
|
||||
return { validValues: ALL_VALUES, defaultValue: "medium" };
|
||||
}
|
||||
// openai-compat, unknown, empty — all values, default medium.
|
||||
return { validValues: ALL_VALUES, defaultValue: "medium" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anthropic family tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function anthropicConfig(m: string): ProviderEffortConfig {
|
||||
// Manual-budget models: claude-3* and claude-opus-4-5.
|
||||
// These use budget_tokens — there is no "default effort level" in the API.
|
||||
if (m.startsWith("claude-3") || m === "claude-opus-4-5") {
|
||||
return {
|
||||
validValues: ["low", "medium", "high"],
|
||||
defaultValue: null,
|
||||
};
|
||||
}
|
||||
// Adaptive models that support xhigh: opus-4-7+, sonnet-5.x, fable-5, mythos-5.
|
||||
// mirrors clamp_adaptive_effort supports_xhigh check.
|
||||
if (
|
||||
m.startsWith("claude-opus-4-7") ||
|
||||
m.startsWith("claude-opus-4-8") ||
|
||||
m.startsWith("claude-sonnet-5") ||
|
||||
m.startsWith("claude-fable-5") ||
|
||||
m.startsWith("claude-mythos-5")
|
||||
) {
|
||||
return {
|
||||
validValues: ["low", "medium", "high", "xhigh", "max"],
|
||||
defaultValue: "high",
|
||||
};
|
||||
}
|
||||
// Adaptive models that do NOT support xhigh: opus-4-6, sonnet-4-6, mythos-preview.
|
||||
if (
|
||||
m.startsWith("claude-opus-4-6") ||
|
||||
m.startsWith("claude-sonnet-4-6") ||
|
||||
m.startsWith("claude-mythos-preview")
|
||||
) {
|
||||
return {
|
||||
validValues: ["low", "medium", "high", "max"],
|
||||
defaultValue: "high",
|
||||
};
|
||||
}
|
||||
// Unknown Anthropic model — assume adaptive with full support.
|
||||
const cap = resolveModelCapabilities(providerId, model ?? "");
|
||||
return {
|
||||
validValues: ["low", "medium", "high", "xhigh", "max"],
|
||||
defaultValue: "high",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI family tables — mirrors openai_efforts_for_model in config.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns true if `m` contains a GPT-5 family token at a word boundary
|
||||
* (not immediately followed by a digit or letter). Mirrors
|
||||
* `gpt5_token_matches` / `gpt5_base_matches` in config.rs.
|
||||
*/
|
||||
function gpt5TokenMatches(m: string, token: string): boolean {
|
||||
let start = 0;
|
||||
while (true) {
|
||||
const idx = m.indexOf(token, start);
|
||||
if (idx === -1) return false;
|
||||
const afterIdx = idx + token.length;
|
||||
const afterChar = afterIdx < m.length ? m[afterIdx] : "";
|
||||
// Boundary: end-of-string or a `-` separator (not a digit or letter).
|
||||
if (afterChar === "" || afterChar === "-") return true;
|
||||
start = afterIdx;
|
||||
}
|
||||
}
|
||||
|
||||
/** Like gpt5TokenMatches but also rejects short -<1-3 digit> suffixes (e.g. -5, -10). */
|
||||
function gpt5BaseMatches(m: string, token: string): boolean {
|
||||
let start = 0;
|
||||
while (true) {
|
||||
const idx = m.indexOf(token, start);
|
||||
if (idx === -1) return false;
|
||||
const afterIdx = idx + token.length;
|
||||
const suffix = m.slice(afterIdx);
|
||||
if (suffix === "") return true;
|
||||
if (!suffix.startsWith("-")) {
|
||||
start = afterIdx;
|
||||
continue;
|
||||
}
|
||||
// Has a `-` suffix — check if it looks like a 1-3 digit version number.
|
||||
const dashRest = suffix.slice(1);
|
||||
if (/^\d{1,3}(?:[^a-z\d]|$)/i.test(dashRest)) {
|
||||
start = afterIdx;
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the model string belongs to any GPT-5 family. */
|
||||
function gpt5FamilyModel(m: string): boolean {
|
||||
return (
|
||||
gpt5TokenMatches(m, "gpt-5-pro") ||
|
||||
gpt5TokenMatches(m, "gpt5-pro") ||
|
||||
gpt5TokenMatches(m, "gpt-5.6") ||
|
||||
gpt5TokenMatches(m, "gpt5.6") ||
|
||||
gpt5TokenMatches(m, "gpt-5-6") ||
|
||||
gpt5TokenMatches(m, "gpt5-6") ||
|
||||
gpt5TokenMatches(m, "gpt-5.5") ||
|
||||
gpt5TokenMatches(m, "gpt5.5") ||
|
||||
gpt5TokenMatches(m, "gpt-5.4") ||
|
||||
gpt5TokenMatches(m, "gpt5.4") ||
|
||||
gpt5TokenMatches(m, "gpt-5.1") ||
|
||||
gpt5TokenMatches(m, "gpt5.1") ||
|
||||
gpt5BaseMatches(m, "gpt-5") ||
|
||||
gpt5BaseMatches(m, "gpt5")
|
||||
);
|
||||
}
|
||||
|
||||
function openaiConfig(m: string): ProviderEffortConfig {
|
||||
// Check -pro before versioned suffixes (gpt-5-pro contains "gpt-5").
|
||||
if (gpt5TokenMatches(m, "gpt-5-pro") || gpt5TokenMatches(m, "gpt5-pro")) {
|
||||
return { validValues: ["high"], defaultValue: "high" };
|
||||
}
|
||||
if (
|
||||
gpt5TokenMatches(m, "gpt-5.6") ||
|
||||
gpt5TokenMatches(m, "gpt5.6") ||
|
||||
gpt5TokenMatches(m, "gpt-5-6") ||
|
||||
gpt5TokenMatches(m, "gpt5-6")
|
||||
) {
|
||||
return {
|
||||
validValues: ["none", "low", "medium", "high", "xhigh", "max"],
|
||||
defaultValue: "medium",
|
||||
};
|
||||
}
|
||||
if (
|
||||
gpt5TokenMatches(m, "gpt-5.5") ||
|
||||
gpt5TokenMatches(m, "gpt5.5") ||
|
||||
gpt5TokenMatches(m, "gpt-5-5") ||
|
||||
gpt5TokenMatches(m, "gpt5-5") ||
|
||||
gpt5TokenMatches(m, "gpt-5.4") ||
|
||||
gpt5TokenMatches(m, "gpt5.4") ||
|
||||
gpt5TokenMatches(m, "gpt-5-4") ||
|
||||
gpt5TokenMatches(m, "gpt5-4")
|
||||
) {
|
||||
return {
|
||||
validValues: ["none", "low", "medium", "high", "xhigh"],
|
||||
defaultValue: "medium",
|
||||
};
|
||||
}
|
||||
if (
|
||||
gpt5TokenMatches(m, "gpt-5.1") ||
|
||||
gpt5TokenMatches(m, "gpt5.1") ||
|
||||
gpt5TokenMatches(m, "gpt-5-1") ||
|
||||
gpt5TokenMatches(m, "gpt5-1")
|
||||
) {
|
||||
return {
|
||||
validValues: ["none", "low", "medium", "high"],
|
||||
defaultValue: "none",
|
||||
};
|
||||
}
|
||||
if (gpt5BaseMatches(m, "gpt-5") || gpt5BaseMatches(m, "gpt5")) {
|
||||
return {
|
||||
validValues: ["minimal", "low", "medium", "high"],
|
||||
defaultValue: "medium",
|
||||
};
|
||||
}
|
||||
// Unknown OpenAI model — conservative fallback; max is enabled only for families whose table includes it.
|
||||
return {
|
||||
validValues: ["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
defaultValue: "medium",
|
||||
validValues: cap.supportedEfforts,
|
||||
defaultValue: cap.defaultEffort,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
[
|
||||
{
|
||||
"note": "Anthropic manual-budget: claude-3 family",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-3-7-sonnet-20250219",
|
||||
"validValues": ["low", "medium", "high"],
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"note": "Anthropic manual-budget: claude-opus-4-5",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-5",
|
||||
"validValues": ["low", "medium", "high"],
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive xhigh-capable: claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-7",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive xhigh-capable: claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-8",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive xhigh-capable: claude-sonnet-5",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-5-20260101",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive xhigh-capable: claude-fable-5",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-fable-5",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive xhigh-capable: claude-opus-5",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-5",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive xhigh-capable: claude-mythos-5",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-mythos-5",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive no-xhigh: claude-opus-4-6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-6",
|
||||
"validValues": ["low", "medium", "high", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive no-xhigh: claude-sonnet-4-6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"validValues": ["low", "medium", "high", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive no-xhigh: claude-mythos-preview",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-mythos-preview",
|
||||
"validValues": ["low", "medium", "high", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic unknown model: blank \u2014 assume full adaptive",
|
||||
"provider": "anthropic",
|
||||
"model": "",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI gpt-5-pro: high only",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5-pro",
|
||||
"validValues": ["high"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI gpt-5.6: none/low/medium/high/xhigh/max",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.6",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI gpt-5.5: none/low/medium/high/xhigh",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.5",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI gpt-5.4: same table as gpt-5.5",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI gpt-5.1: none/low/medium/high",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.1",
|
||||
"validValues": ["none", "low", "medium", "high"],
|
||||
"defaultValue": "none"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI gpt-5 base: minimal/low/medium/high",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5",
|
||||
"validValues": ["minimal", "low", "medium", "high"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI unknown model (gpt-4o): all-except-max",
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI empty model: all-except-max",
|
||||
"provider": "openai",
|
||||
"model": "",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 claude route (claude-opus-4-7): xhigh-capable anthropic table",
|
||||
"provider": "databricks_v2",
|
||||
"model": "claude-opus-4-7",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 claude route with databricks- prefix stripped",
|
||||
"provider": "databricks_v2",
|
||||
"model": "databricks-claude-opus-4-7",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 gpt-5.6-sol route: OpenAI max-capable table",
|
||||
"provider": "databricks_v2",
|
||||
"model": "gpt-5.6-sol",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 gpt-5-6-sol route: dashed OpenAI max-capable table",
|
||||
"provider": "databricks_v2",
|
||||
"model": "gpt-5-6-sol",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 gpt-5.4 route: OpenAI gpt-5.5/5.4 table",
|
||||
"provider": "databricks_v2",
|
||||
"model": "gpt-5.4",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 gpt-5.1 with databricks- prefix: OpenAI gpt-5.1 table",
|
||||
"provider": "databricks_v2",
|
||||
"model": "databricks-gpt-5.1",
|
||||
"validValues": ["none", "low", "medium", "high"],
|
||||
"defaultValue": "none"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 concrete non-claude non-gpt5 (llama-3): MLflow path, all-except-max",
|
||||
"provider": "databricks_v2",
|
||||
"model": "llama-3",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 blank model: route unknown, all-7",
|
||||
"provider": "databricks_v2",
|
||||
"model": "",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "databricks v1: routes like openai unknown, all-except-max",
|
||||
"provider": "databricks",
|
||||
"model": "",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "openai-compat: all-7 with medium default",
|
||||
"provider": "openai-compat",
|
||||
"model": "",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "openrouter: all-7 with medium default",
|
||||
"provider": "openrouter",
|
||||
"model": "",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "empty provider: all-7 with medium default",
|
||||
"provider": "",
|
||||
"model": "",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "databricks_v2 goose-claude-fable-5: strips goose- prefix, routes anthropic adaptive+xhigh, max valid",
|
||||
"provider": "databricks_v2",
|
||||
"model": "goose-claude-fable-5",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "databricks_v2 goose-gpt-5.5: strips goose- prefix, routes openai gpt-5.5 table (none+low-xhigh, no minimal)",
|
||||
"provider": "databricks_v2",
|
||||
"model": "goose-gpt-5.5",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "databricks_v2 goose-claude-sonnet-5: strips goose- prefix, routes anthropic adaptive+xhigh",
|
||||
"provider": "databricks_v2",
|
||||
"model": "goose-claude-sonnet-5",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "databricks_v2 arbitrary prefix team-x-claude-opus-4-7: strips to claude-opus-4-7, routes anthropic adaptive+xhigh, max valid",
|
||||
"provider": "databricks_v2",
|
||||
"model": "team-x-claude-opus-4-7",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
}
|
||||
]
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Effort-table sync guard: TS side.
|
||||
*
|
||||
* Loads the checked-in fixture and asserts that `getProviderEffortConfig`
|
||||
* matches every entry. Drift between `buzzAgentConfig.ts` and the fixture
|
||||
* (e.g. a new model family added to one side but not the other) fails CI.
|
||||
* The companion Rust test in `crates/buzz-agent/src/config.rs` mirrors
|
||||
* this check so both sides of the mirror must stay in sync.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
import { getProviderEffortConfig } from "./buzzAgentConfig.ts";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const fixture = JSON.parse(
|
||||
readFileSync(path.join(__dirname, "effortTable.fixture.json"), "utf8"),
|
||||
);
|
||||
|
||||
for (const entry of fixture) {
|
||||
const {
|
||||
note,
|
||||
provider,
|
||||
model,
|
||||
validValues: expectedValidValues,
|
||||
defaultValue: expectedDefault,
|
||||
} = entry;
|
||||
const label = note ?? `${provider}/${model}`;
|
||||
|
||||
test(`effort fixture: ${label}`, () => {
|
||||
const { validValues, defaultValue } = getProviderEffortConfig(
|
||||
provider,
|
||||
model,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...validValues],
|
||||
expectedValidValues,
|
||||
`validValues mismatch for "${label}"`,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
defaultValue,
|
||||
expectedDefault,
|
||||
`defaultValue mismatch for "${label}"`,
|
||||
);
|
||||
});
|
||||
}
|
||||
372
desktop/src/features/agents/ui/modelCapabilities.ts
Normal file
372
desktop/src/features/agents/ui/modelCapabilities.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Runtime model-capability interpreter (TypeScript).
|
||||
*
|
||||
* `scripts/model-capabilities.json` is the single source of truth for every
|
||||
* model's six-axis capability profile (thinking mode, supported efforts,
|
||||
* default effort, Databricks v2 wire route, normalization policy, and picker
|
||||
* label). This module imports that manifest and interprets it at runtime,
|
||||
* mirroring the Rust interpreter in `crates/buzz-agent/src/model_capabilities.rs`
|
||||
* line for line. There is no codegen: both interpreters read the same
|
||||
* hand-curated manifest, and the shared normative corpus
|
||||
* (`scripts/normative-corpus.json`) is the cross-language contract that
|
||||
* guarantees they agree.
|
||||
*
|
||||
* ## Resolution algorithm (`resolveModelCapabilities`)
|
||||
* 1. Provider canonicalization (trim, lowercase, alias map) — done by the
|
||||
* caller via `canonicalizeProvider`.
|
||||
* 2. Provider-qualified exact-record lookup (case-insensitive on the id).
|
||||
* 3. Boundary-aware family-rule match: strip any endpoint prefix at the first
|
||||
* family token on a non-alphanumeric boundary, then take the longest match
|
||||
* across every rule's `matchValue` and `matchAliases`, breaking ties on the
|
||||
* lexicographically smallest rule id.
|
||||
* 4. Provider fallback, distinguishing a blank model id from a
|
||||
* concrete-unknown one.
|
||||
*
|
||||
* Every path yields a complete six-axis result; `registryLabel` is populated
|
||||
* only on an exact-record hit.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import manifestJson from "@model-capabilities-manifest";
|
||||
|
||||
/** Valid thinking-effort values accepted by buzz-agent (mirrors parse_thinking_effort in config.rs). */
|
||||
export const THINKING_EFFORT_VALUES = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
] as const;
|
||||
export type ThinkingEffortValue = (typeof THINKING_EFFORT_VALUES)[number];
|
||||
|
||||
/** Databricks v2 wire route. `not-applicable` for non-DBv2 providers. */
|
||||
export type DatabricksV2WireRoute =
|
||||
| "openai-responses"
|
||||
| "anthropic-messages"
|
||||
| "mlflow-chat"
|
||||
| "route-unknown"
|
||||
| "not-applicable";
|
||||
|
||||
/** How a model activates and controls reasoning depth on the wire. */
|
||||
export type ThinkingMode =
|
||||
| "adaptive"
|
||||
| "manual-budget"
|
||||
| "none"
|
||||
| "omit-fields";
|
||||
|
||||
/** Post-resolution effort normalization applied before a request is sent. */
|
||||
export type NormalizationPolicy =
|
||||
| "none"
|
||||
| "openai-standard"
|
||||
| "openai-clamp-max-to-xhigh";
|
||||
|
||||
/** Complete resolved capability record for a (provider, rawModelId) pair. Every axis populated. */
|
||||
export type CapabilityResult = {
|
||||
readonly thinkingMode: ThinkingMode;
|
||||
readonly supportedEfforts: ReadonlyArray<ThinkingEffortValue>;
|
||||
readonly defaultEffort: ThinkingEffortValue | null;
|
||||
readonly databricksV2WireRoute: DatabricksV2WireRoute;
|
||||
readonly normalizationPolicy: NormalizationPolicy;
|
||||
/** Static display label. Populated only on a provider-qualified exact-record hit. */
|
||||
readonly registryLabel: string | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest schema — runtime-validates the bundled manifest, mirroring the
|
||||
// strict serde (`deny_unknown_fields` + real enums) + validate_manifest in the
|
||||
// Rust interpreter. A malformed bundled manifest is a build-time data error
|
||||
// that must never ship, so parse failure throws.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EffortSchema = z.enum(THINKING_EFFORT_VALUES);
|
||||
const ThinkingModeSchema = z.enum([
|
||||
"adaptive",
|
||||
"manual-budget",
|
||||
"none",
|
||||
"omit-fields",
|
||||
]);
|
||||
const WireRouteSchema = z.enum([
|
||||
"openai-responses",
|
||||
"anthropic-messages",
|
||||
"mlflow-chat",
|
||||
"route-unknown",
|
||||
"not-applicable",
|
||||
]);
|
||||
const NormalizationSchema = z.enum([
|
||||
"none",
|
||||
"openai-standard",
|
||||
"openai-clamp-max-to-xhigh",
|
||||
]);
|
||||
|
||||
const FamilyRuleSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
match_kind: z.enum(["exact", "prefix"]),
|
||||
match_value: z.string(),
|
||||
match_aliases: z.array(z.string()).default([]),
|
||||
providers: z.array(z.string()),
|
||||
thinking_mode: ThinkingModeSchema,
|
||||
supported_efforts: z.array(EffortSchema),
|
||||
default_effort: EffortSchema.nullable(),
|
||||
databricks_v2_wire_route: WireRouteSchema,
|
||||
normalization_policy: NormalizationSchema,
|
||||
// Documentation-only key; modeled so strict parsing accepts the manifest
|
||||
// while still rejecting an unmodeled (typo'd) field. Mirrors the Rust
|
||||
// `FamilyRule` doc field under `deny_unknown_fields`.
|
||||
_comment: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ExactRecordSchema = z
|
||||
.object({
|
||||
provider: z.string(),
|
||||
raw_model_id: z.string(),
|
||||
registry_label: z.string(),
|
||||
thinking_mode: ThinkingModeSchema,
|
||||
supported_efforts: z.array(EffortSchema),
|
||||
default_effort: EffortSchema.nullable(),
|
||||
databricks_v2_wire_route: WireRouteSchema,
|
||||
normalization_policy: NormalizationSchema,
|
||||
// Documentation/provenance keys; modeled for strict parsing, not read at
|
||||
// runtime. Mirrors the Rust `ExactRecord` doc fields under
|
||||
// `deny_unknown_fields`.
|
||||
_provenance: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
_source: z.string().optional(),
|
||||
_reconciliation: z.string().optional(),
|
||||
_reconciliation_note: z.string().optional(),
|
||||
_reconciliation_doc: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const FallbackStateSchema = z
|
||||
.object({
|
||||
databricks_v2_wire_route: WireRouteSchema,
|
||||
thinking_mode: ThinkingModeSchema,
|
||||
supported_efforts: z.array(EffortSchema),
|
||||
default_effort: EffortSchema.nullable(),
|
||||
normalization_policy: NormalizationSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const FallbackPairSchema = z
|
||||
.object({
|
||||
blank: FallbackStateSchema,
|
||||
concrete_unknown: FallbackStateSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Fixed named providers with a `_default` catch-all, mirroring the Rust
|
||||
// `ProviderFallbacks` struct. Enumerating the keys structurally guarantees
|
||||
// `_default` is present (the resolver's total-function backstop).
|
||||
const ProviderFallbacksSchema = z
|
||||
.object({
|
||||
anthropic: FallbackPairSchema,
|
||||
openai: FallbackPairSchema,
|
||||
databricks: FallbackPairSchema,
|
||||
databricks_v2: FallbackPairSchema,
|
||||
openrouter: FallbackPairSchema,
|
||||
_default: FallbackPairSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ManifestSchema = z
|
||||
.object({
|
||||
family_tokens: z.array(z.string()).min(1),
|
||||
family_rules: z.array(FamilyRuleSchema),
|
||||
databricks_v2_known_models: z.array(z.string()),
|
||||
exact_records: z.array(ExactRecordSchema),
|
||||
provider_fallbacks: ProviderFallbacksSchema,
|
||||
// Root documentation keys; modeled for strict parsing, not read at runtime.
|
||||
// Mirrors the Rust `Manifest` doc fields under `deny_unknown_fields`.
|
||||
_comment: z.string().optional(),
|
||||
_comment_databricks_v2_known_models: z.string().optional(),
|
||||
_sources: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type ParsedManifest = z.infer<typeof ManifestSchema>;
|
||||
type FamilyRule = z.infer<typeof FamilyRuleSchema>;
|
||||
type FallbackState = z.infer<typeof FallbackStateSchema>;
|
||||
type FallbackPair = z.infer<typeof FallbackPairSchema>;
|
||||
|
||||
const MANIFEST: ParsedManifest = ManifestSchema.parse(manifestJson);
|
||||
|
||||
// Prototype-safe provider→fallback lookup. A plain-object index would return
|
||||
// `Object.prototype.constructor` / `Object.prototype` for the adversarial
|
||||
// providers `constructor` / `__proto__`, defeating the `_default` catch-all;
|
||||
// a Map keys only on real entries. Mirrors `ProviderFallbacks::get`.
|
||||
const PROVIDER_FALLBACKS: ReadonlyMap<string, FallbackPair> = new Map(
|
||||
Object.entries(MANIFEST.provider_fallbacks),
|
||||
);
|
||||
|
||||
function fallbackPair(canon: string): FallbackPair {
|
||||
// `_default` is a required key on `ProviderFallbacksSchema`, so referencing
|
||||
// it directly is typed as a non-optional `FallbackPair` — no assertion, and
|
||||
// the total-function backstop is guaranteed by the schema, not by `!`.
|
||||
return PROVIDER_FALLBACKS.get(canon) ?? MANIFEST.provider_fallbacks._default;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider canonicalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROVIDER_ALIASES = new Map<string, string>([
|
||||
["openai-compat", "openai"],
|
||||
["databricks-v2", "databricks_v2"],
|
||||
]);
|
||||
|
||||
/** Canonicalize a provider name: trim, lowercase, apply the alias map. */
|
||||
export function canonicalizeProvider(provider: string): string {
|
||||
const canon = provider.trim().toLowerCase();
|
||||
return PROVIDER_ALIASES.get(canon) ?? canon;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boundary-aware prefix helpers — mirror strip_catalog_prefix / prefix_matches.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isAsciiAlphanumeric(ch: string): boolean {
|
||||
return /^[a-z0-9]$/i.test(ch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip an endpoint-naming prefix by locating the earliest family token that
|
||||
* begins on a non-alphanumeric boundary (or at the start), returning the slice
|
||||
* from that token onward. Returns the input unchanged when no token qualifies.
|
||||
*/
|
||||
export function stripCatalogPrefix(
|
||||
modelLower: string,
|
||||
familyTokens: ReadonlyArray<string>,
|
||||
): string {
|
||||
let best = Number.POSITIVE_INFINITY;
|
||||
for (const tok of familyTokens) {
|
||||
let from = 0;
|
||||
while (true) {
|
||||
const idx = modelLower.indexOf(tok, from);
|
||||
if (idx === -1) break;
|
||||
if (idx === 0 || !isAsciiAlphanumeric(modelLower[idx - 1])) {
|
||||
if (idx < best) best = idx;
|
||||
break;
|
||||
}
|
||||
from = idx + 1;
|
||||
}
|
||||
}
|
||||
return best === Number.POSITIVE_INFINITY
|
||||
? modelLower
|
||||
: modelLower.slice(best);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary-aware prefix test: `s` equals `token`, or `s` starts with `token`
|
||||
* and the following character is a non-alphanumeric boundary.
|
||||
*/
|
||||
function prefixMatches(token: string, s: string): boolean {
|
||||
if (!s.startsWith(token)) return false;
|
||||
const rest = s.slice(token.length);
|
||||
return rest.length === 0 || !isAsciiAlphanumeric(rest[0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toResult(
|
||||
axes: FamilyRule | FallbackState,
|
||||
route: DatabricksV2WireRoute,
|
||||
registryLabel: string | null,
|
||||
): CapabilityResult {
|
||||
return {
|
||||
thinkingMode: axes.thinking_mode,
|
||||
supportedEfforts: axes.supported_efforts,
|
||||
defaultEffort: axes.default_effort,
|
||||
databricksV2WireRoute: route,
|
||||
normalizationPolicy: axes.normalization_policy,
|
||||
registryLabel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the capability profile for a `(provider, rawModelId)` pair.
|
||||
*
|
||||
* Total function — always returns a complete result. Provider canonicalization
|
||||
* happens inside the resolver (trim, lowercase, alias map), so callers pass raw
|
||||
* provider names. Mirrors `resolve` in the Rust interpreter exactly.
|
||||
*/
|
||||
export function resolveModelCapabilities(
|
||||
provider: string,
|
||||
rawModelId: string,
|
||||
): CapabilityResult {
|
||||
const canon = canonicalizeProvider(provider);
|
||||
const blank = rawModelId.trim().length === 0;
|
||||
|
||||
// 1. Provider-qualified exact-record lookup (case-insensitive on the id).
|
||||
if (!blank) {
|
||||
const idLower = rawModelId.toLowerCase();
|
||||
for (const rec of MANIFEST.exact_records) {
|
||||
if (
|
||||
rec.provider === canon &&
|
||||
rec.raw_model_id.toLowerCase() === idLower
|
||||
) {
|
||||
return toResult(rec, rec.databricks_v2_wire_route, rec.registry_label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Boundary-aware family match: longest token wins, lexicographic tie-break.
|
||||
if (!blank) {
|
||||
const modelLower = rawModelId.toLowerCase();
|
||||
const stripped = stripCatalogPrefix(modelLower, MANIFEST.family_tokens);
|
||||
let best: { len: number; rule: FamilyRule } | null = null;
|
||||
for (const rule of MANIFEST.family_rules) {
|
||||
if (!rule.providers.includes(canon)) continue;
|
||||
let matched: number | null = null;
|
||||
for (const tok of [rule.match_value, ...rule.match_aliases]) {
|
||||
const ok =
|
||||
rule.match_kind === "exact"
|
||||
? stripped === tok
|
||||
: prefixMatches(tok, stripped);
|
||||
if (ok)
|
||||
matched =
|
||||
matched === null ? tok.length : Math.max(matched, tok.length);
|
||||
}
|
||||
if (matched !== null) {
|
||||
const better =
|
||||
best === null ||
|
||||
matched > best.len ||
|
||||
(matched === best.len && rule.id < best.rule.id);
|
||||
if (better) best = { len: matched, rule };
|
||||
}
|
||||
}
|
||||
if (best !== null) {
|
||||
const route: DatabricksV2WireRoute =
|
||||
canon === "databricks_v2"
|
||||
? best.rule.databricks_v2_wire_route
|
||||
: "not-applicable";
|
||||
return toResult(best.rule, route, null);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Provider fallback (blank vs. concrete-unknown); never carries a label.
|
||||
const pair = fallbackPair(canon);
|
||||
const state = blank ? pair.blank : pair.concrete_unknown;
|
||||
return toResult(state, state.databricks_v2_wire_route, null);
|
||||
}
|
||||
|
||||
/** Authoritative list of known Databricks v2 model ids, sourced from the manifest. */
|
||||
export const DATABRICKS_V2_KNOWN_MODELS: ReadonlyArray<string> =
|
||||
MANIFEST.databricks_v2_known_models;
|
||||
|
||||
/**
|
||||
* Databricks endpoint-id → display-name registry, derived at runtime from the
|
||||
* manifest's `databricks_v2` exact records (the only exact records that carry a
|
||||
* `registry_label`). Feeds the providerless registry tier of
|
||||
* `resolveModelLabel`. Derived, not hand-listed — the manifest stays the single
|
||||
* source of truth, so there is no second table to keep in sync.
|
||||
*/
|
||||
export const DATABRICKS_MODEL_NAMES: ReadonlyMap<string, string> = new Map(
|
||||
MANIFEST.exact_records
|
||||
.filter((rec) => rec.provider === "databricks_v2")
|
||||
.map((rec) => [rec.raw_model_id, rec.registry_label] as const),
|
||||
);
|
||||
122
desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs
Normal file
122
desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
ManifestSchema,
|
||||
resolveModelCapabilities,
|
||||
} from "./modelCapabilities.ts";
|
||||
|
||||
// The normative corpus (`scripts/normative-corpus.json`) is the cross-language
|
||||
// contract: the Rust interpreter's test suite runs the same executable vectors
|
||||
// through its `resolve`, so a green run here proves the TS interpreter agrees
|
||||
// with Rust axis-for-axis. Loaded by relative path — the corpus never passes
|
||||
// through vite/tsc, so it needs no import alias.
|
||||
const corpusUrl = new URL(
|
||||
"../../../../../scripts/normative-corpus.json",
|
||||
import.meta.url,
|
||||
);
|
||||
const corpus = JSON.parse(readFileSync(fileURLToPath(corpusUrl), "utf8"));
|
||||
|
||||
// A vector is executable iff it carries an `expect` block; section markers
|
||||
// (`_group`) are skipped. Mirrors the Rust corpus filter.
|
||||
const executable = corpus.filter((entry) => entry.expect != null);
|
||||
|
||||
test("corpus has exactly 103 executable vectors", () => {
|
||||
// Locks the vector count so a silent corpus edit can't quietly drop coverage;
|
||||
// must equal the gate in the Rust suite (model_capabilities.rs).
|
||||
assert.equal(executable.length, 103);
|
||||
});
|
||||
|
||||
test("every executable corpus vector resolves to its expected six-axis profile", () => {
|
||||
for (const entry of executable) {
|
||||
const id = entry.id ?? "<no-id>";
|
||||
const got = resolveModelCapabilities(
|
||||
entry.provider ?? "",
|
||||
entry.raw_model_id ?? "",
|
||||
);
|
||||
const want = entry.expect;
|
||||
assert.equal(got.thinkingMode, want.thinking_mode, `${id}: thinkingMode`);
|
||||
assert.deepEqual(
|
||||
[...got.supportedEfforts],
|
||||
want.supported_efforts,
|
||||
`${id}: supportedEfforts`,
|
||||
);
|
||||
assert.equal(
|
||||
got.defaultEffort,
|
||||
want.default_effort,
|
||||
`${id}: defaultEffort`,
|
||||
);
|
||||
assert.equal(
|
||||
got.databricksV2WireRoute,
|
||||
want.databricks_v2_wire_route,
|
||||
`${id}: databricksV2WireRoute`,
|
||||
);
|
||||
assert.equal(
|
||||
got.normalizationPolicy,
|
||||
want.normalization_policy,
|
||||
`${id}: normalizationPolicy`,
|
||||
);
|
||||
assert.equal(
|
||||
got.registryLabel,
|
||||
want.registry_label,
|
||||
`${id}: registryLabel`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("registryLabel axis is exercised by at least 12 exact-record vectors", () => {
|
||||
// The registryLabel axis only populates on an exact-record hit; guard that
|
||||
// the corpus keeps covering it so a regression there can't pass unnoticed.
|
||||
const labeled = executable.filter((e) => e.expect.registry_label != null);
|
||||
assert.ok(
|
||||
labeled.length >= 12,
|
||||
`expected >=12 labeled vectors, got ${labeled.length}`,
|
||||
);
|
||||
for (const entry of labeled) {
|
||||
const got = resolveModelCapabilities(
|
||||
entry.provider ?? "",
|
||||
entry.raw_model_id ?? "",
|
||||
);
|
||||
assert.equal(
|
||||
got.registryLabel,
|
||||
entry.expect.registry_label,
|
||||
`${entry.id ?? "<no-id>"}: registryLabel`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The TS manifest schema mirrors Rust's `#[serde(deny_unknown_fields)]`: a
|
||||
// misspelled key must fail in BOTH languages, not pass silently on desktop.
|
||||
// Loaded by relative path — same rationale as the corpus above.
|
||||
const manifestUrl = new URL(
|
||||
"../../../../../scripts/model-capabilities.json",
|
||||
import.meta.url,
|
||||
);
|
||||
const manifestJson = JSON.parse(
|
||||
readFileSync(fileURLToPath(manifestUrl), "utf8"),
|
||||
);
|
||||
|
||||
test("ManifestSchema accepts the committed manifest verbatim", () => {
|
||||
// The strict schema must model every documented key the manifest actually
|
||||
// ships (`_comment`, `_provenance`, `source`, `_sources`, …); a green parse
|
||||
// here proves strictness didn't over-reach and break the real data.
|
||||
assert.doesNotThrow(() => ManifestSchema.parse(manifestJson));
|
||||
});
|
||||
|
||||
test("ManifestSchema rejects an unknown top-level field", () => {
|
||||
// Mirrors Rust `deny_unknown_fields`: a typo'd root key is a hard error, not
|
||||
// an ignored no-op. Without `.strict()` this passed on desktop while Rust
|
||||
// failed — the exact drift this alignment closes.
|
||||
const withTypo = { ...manifestJson, faimly_rules: [] };
|
||||
assert.throws(() => ManifestSchema.parse(withTypo));
|
||||
});
|
||||
|
||||
test("ManifestSchema rejects an unknown field inside an exact record", () => {
|
||||
// Strictness must reach nested objects too, not just the root — an exact
|
||||
// record with a stray key is where a hand-edit typo most plausibly lands.
|
||||
const mutated = structuredClone(manifestJson);
|
||||
mutated.exact_records[0].raw_modle_id = "typo";
|
||||
assert.throws(() => ManifestSchema.parse(mutated));
|
||||
});
|
||||
@@ -312,3 +312,130 @@ test("isSuccessfulEmptyDiscovery_stillPending_isFalse", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Discovered rows resolve through the shared label resolver ────────────────
|
||||
// Discovery can return a Databricks endpoint with a null or blank `name`
|
||||
// (v1 catalogs, and any harness that echoes IDs only). Those rows must still
|
||||
// show the curated registry name rather than the raw endpoint ID.
|
||||
|
||||
test("discoveredRow_knownDatabricksIdWithNullName_showsCuratedName", () => {
|
||||
const options = getDiscoveredPersonaModelOptions(
|
||||
response({
|
||||
models: [{ id: "databricks-gpt-5-5", name: null, description: null }],
|
||||
}),
|
||||
"",
|
||||
);
|
||||
|
||||
assert.deepEqual(options.slice(1), [
|
||||
{ id: "databricks-gpt-5-5", label: "GPT-5.5" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("discoveredRow_knownDatabricksIdWithBlankName_showsCuratedName", () => {
|
||||
const options = getDiscoveredPersonaModelOptions(
|
||||
response({
|
||||
models: [
|
||||
{ id: "databricks-claude-opus-4-7", name: " ", description: null },
|
||||
],
|
||||
}),
|
||||
"",
|
||||
);
|
||||
|
||||
assert.deepEqual(options.slice(1), [
|
||||
{ id: "databricks-claude-opus-4-7", label: "Claude Opus 4.7" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("discoveredRow_unknownCustomEndpointWithNoName_showsRawId", () => {
|
||||
const options = getDiscoveredPersonaModelOptions(
|
||||
response({
|
||||
models: [
|
||||
{ id: "databricks-team-2025-01", name: null, description: null },
|
||||
],
|
||||
}),
|
||||
"",
|
||||
);
|
||||
|
||||
assert.deepEqual(options.slice(1), [
|
||||
{ id: "databricks-team-2025-01", label: "databricks-team-2025-01" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("discoveredRow_nonblankDiscoveredName_winsOverRegistry", () => {
|
||||
const options = getDiscoveredPersonaModelOptions(
|
||||
response({
|
||||
models: [
|
||||
{ id: "databricks-gpt-5-5", name: "Workspace GPT", description: null },
|
||||
],
|
||||
}),
|
||||
"",
|
||||
);
|
||||
|
||||
assert.deepEqual(options.slice(1), [
|
||||
{ id: "databricks-gpt-5-5", label: "Workspace GPT" },
|
||||
]);
|
||||
});
|
||||
|
||||
// ── Real buzz-agent discovery shape: name echoes the id ─────────────────────
|
||||
// buzz-agent's Databricks discovery emits {id, name: id} on every path (the
|
||||
// API has no display-name field). The echoed name must not short-circuit the
|
||||
// registry tier, so a known id still shows its curated label.
|
||||
|
||||
test("discoveredRow_knownDatabricksIdEchoedName_showsCuratedName", () => {
|
||||
const options = getDiscoveredPersonaModelOptions(
|
||||
response({
|
||||
models: [
|
||||
{
|
||||
id: "databricks-gpt-5-5",
|
||||
name: "databricks-gpt-5-5",
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"databricks_v2",
|
||||
);
|
||||
|
||||
assert.deepEqual(options.slice(1), [
|
||||
{ id: "databricks-gpt-5-5", label: "GPT-5.5" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("discoveredRow_unknownDatabricksIdEchoedName_showsRawId", () => {
|
||||
const options = getDiscoveredPersonaModelOptions(
|
||||
response({
|
||||
models: [
|
||||
{
|
||||
id: "databricks-team-2025-01",
|
||||
name: "databricks-team-2025-01",
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"databricks_v2",
|
||||
);
|
||||
|
||||
assert.deepEqual(options.slice(1), [
|
||||
{ id: "databricks-team-2025-01", label: "databricks-team-2025-01" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("discoveredRow_defaultCatalogSuffixedName_winsOverRegistry", () => {
|
||||
// The auth-empty fallback carries a distinct curated+suffixed name; tier 1
|
||||
// correctly keeps it rather than re-deriving the bare label.
|
||||
const options = getDiscoveredPersonaModelOptions(
|
||||
response({
|
||||
models: [
|
||||
{
|
||||
id: "databricks-gpt-5-5",
|
||||
name: "GPT-5.5 (default catalog)",
|
||||
description: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
"databricks_v2",
|
||||
);
|
||||
|
||||
assert.deepEqual(options.slice(1), [
|
||||
{ id: "databricks-gpt-5-5", label: "GPT-5.5 (default catalog)" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "./personaModelDiscoveryStatus";
|
||||
import type { PersonaModelOption } from "./agentConfigOptions";
|
||||
import { providerRequiresExplicitModel } from "./agentConfigOptions";
|
||||
import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
|
||||
|
||||
export const MODEL_DISCOVERY_LOADING_VALUE = "__model_discovery_loading__";
|
||||
|
||||
@@ -64,7 +65,7 @@ export function getDiscoveredPersonaModelOptions(
|
||||
provider === "relay-mesh"
|
||||
? "Default (auto)"
|
||||
: agentDefaultModel
|
||||
? `Default model (${agentDefaultModel})`
|
||||
? `Default model (${resolveModelLabel(agentDefaultModel, null, provider)})`
|
||||
: "Default model",
|
||||
},
|
||||
];
|
||||
@@ -77,7 +78,7 @@ export function getDiscoveredPersonaModelOptions(
|
||||
...defaultModelOption,
|
||||
...explicitModels.map((model) => ({
|
||||
id: model.id,
|
||||
label: model.name?.trim() || model.id,
|
||||
label: resolveModelLabel(model.id, model.name, provider),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
|
||||
import { useNow } from "@/shared/lib/useNow";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
|
||||
|
||||
type UserProfilePopoverProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -411,7 +412,13 @@ export function UserProfilePopover({
|
||||
<InfoBadge>{runtimeLabel(relayAgent.agentType)}</InfoBadge>
|
||||
) : null}
|
||||
{managedAgent?.model ? (
|
||||
<InfoBadge>{managedAgent.model}</InfoBadge>
|
||||
<InfoBadge>
|
||||
{resolveModelLabel(
|
||||
managedAgent.model,
|
||||
null,
|
||||
managedAgent.provider,
|
||||
)}
|
||||
</InfoBadge>
|
||||
) : null}
|
||||
{managedAgent?.acpCommand ? (
|
||||
<InfoBadge>ACP: {managedAgent.acpCommand}</InfoBadge>
|
||||
|
||||
@@ -89,6 +89,10 @@ export function resolve(specifier, context, nextResolve) {
|
||||
const resolved = path.join(repoRoot, "preview-features.json");
|
||||
return nextResolve(toFileSpecifier(resolved), context);
|
||||
}
|
||||
if (specifier === "@model-capabilities-manifest") {
|
||||
const resolved = path.join(repoRoot, "scripts", "model-capabilities.json");
|
||||
return nextResolve(toFileSpecifier(resolved), context);
|
||||
}
|
||||
if (specifier.startsWith("@/")) {
|
||||
const stripped = specifier.slice(2);
|
||||
// Preserve explicit extensions (.mjs, .js, .json, .ts, etc.). The bundler
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@features-manifest": ["../preview-features.json"]
|
||||
"@features-manifest": ["../preview-features.json"],
|
||||
"@model-capabilities-manifest": ["../scripts/model-capabilities.json"]
|
||||
},
|
||||
|
||||
/* Bundler mode */
|
||||
|
||||
@@ -25,6 +25,10 @@ export default defineConfig(async () => ({
|
||||
alias: {
|
||||
"@": "/src",
|
||||
"@features-manifest": path.resolve(__dirname, "../preview-features.json"),
|
||||
"@model-capabilities-manifest": path.resolve(
|
||||
__dirname,
|
||||
"../scripts/model-capabilities.json",
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
1140
scripts/model-capabilities.json
Normal file
1140
scripts/model-capabilities.json
Normal file
File diff suppressed because it is too large
Load Diff
2074
scripts/normative-corpus.json
Normal file
2074
scripts/normative-corpus.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,14 @@ run_unit_tests() {
|
||||
# the two lists must stay in step or the fallback silently covers less.
|
||||
run_test_step "buzz-backend-kubernetes tests" \
|
||||
cargo test -p buzz-backend-kubernetes -- --nocapture
|
||||
|
||||
# buzz-agent model-capabilities corpus: the Rust half of the cross-language
|
||||
# drift guard. model_capabilities.rs embeds scripts/model-capabilities.json +
|
||||
# scripts/normative-corpus.json via include_str! and replays all 103 vectors
|
||||
# as pure in-process tests (no infra). Mirrors the nextest path in
|
||||
# `just test-unit` — the two lists must stay in step.
|
||||
run_test_step "buzz-agent unit tests" \
|
||||
cargo test -p buzz-agent --lib -- --nocapture
|
||||
}
|
||||
|
||||
# ---- DB / integration tests (infra required) --------------------------------
|
||||
|
||||
Reference in New Issue
Block a user