From 38558504ca8bf74f0925928ca46be0aa828d2e0f Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 29 Jul 2026 15:41:30 -0400 Subject: [PATCH] fix(catalog): address pass-1 review blockers on Databricks model-label PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Generator emits both Rust and TypeScript registries in one invocation; both are formatted (cargo fmt, biome) so regenerate is byte-for-byte stable - TS registry uses Map — eliminates Object.prototype key hole where 'constructor', '__proto__', 'toString' resolved through Object.prototype instead of passing through as raw IDs - Centralize three-tier resolver (resolveModelLabel) in formatAgentModelLabel.ts; apply to ModelPicker trigger + unsupported-switching state, usePersonaModelDiscovery default rows, and AgentConfigFields defaultModelLabel — all surfaces now show curated names where available instead of raw endpoint IDs - lib.rs module ordering fixed; just fmt-check green - Tests: prototype-key cases (constructor, __proto__, toString, hasOwnProperty), resolver precedence (discovered name wins, blank falls through), DATABRICKS_MODEL_NAMES Map type assertion, Rust/TS parity spot-check; 3794 TS tests pass, 0 fail - AGENTS.md refresh instructions updated: generator emits both files, resolveModelLabel is the single frontend resolver entry point; curl --fail + --max-time 30, explicit JSON shape validation, key/value character validation added to generator Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../buzz-agent/src/databricks_model_names.rs | 13 +- crates/buzz-agent/src/lib.rs | 2 +- desktop/src/features/agents/AGENTS.md | 2 +- .../agents/lib/agentCardModelLabel.ts | 8 +- .../agents/lib/databricksModelNames.test.mjs | 148 ++++++++++++++--- .../agents/lib/databricksModelNames.ts | 77 ++++----- .../agents/lib/formatAgentModelLabel.ts | 30 +++- .../features/agents/ui/AgentConfigFields.tsx | 5 +- .../features/agents/ui/ManagedAgentRow.tsx | 4 +- .../src/features/agents/ui/ModelPicker.tsx | 15 +- .../agents/ui/usePersonaModelDiscovery.ts | 3 +- .../profile/ui/UserProfilePopover.tsx | 4 +- scripts/generate-databricks-model-names.py | 155 +++++++++++++++--- 13 files changed, 354 insertions(+), 112 deletions(-) diff --git a/crates/buzz-agent/src/databricks_model_names.rs b/crates/buzz-agent/src/databricks_model_names.rs index fc83e9e39..8d71b4e7c 100644 --- a/crates/buzz-agent/src/databricks_model_names.rs +++ b/crates/buzz-agent/src/databricks_model_names.rs @@ -1,7 +1,6 @@ // GENERATED by scripts/generate-databricks-model-names.py // Source: https://models.dev/api.json -- providers.databricks.models -// Refresh: python3 scripts/generate-databricks-model-names.py \ -// > crates/buzz-agent/src/databricks_model_names.rs +// Refresh: python3 scripts/generate-databricks-model-names.py // // Do not hand-edit -- rerun the script to update. @@ -22,8 +21,14 @@ pub(crate) static DATABRICKS_MODEL_NAMES: &[(&str, &str)] = &[ ("databricks-claude-sonnet-4-6", "Claude Sonnet 4.6"), ("databricks-gemini-2-5-flash", "Gemini 2.5 Flash"), ("databricks-gemini-2-5-pro", "Gemini 2.5 Pro"), - ("databricks-gemini-3-1-flash-lite", "Gemini 3.1 Flash Lite Preview"), - ("databricks-gemini-3-1-pro", "Gemini 3.1 Pro Preview Custom Tools"), + ( + "databricks-gemini-3-1-flash-lite", + "Gemini 3.1 Flash Lite Preview", + ), + ( + "databricks-gemini-3-1-pro", + "Gemini 3.1 Pro Preview Custom Tools", + ), ("databricks-gemini-3-flash", "Gemini 3 Flash Preview"), ("databricks-gemini-3-pro", "Gemini 3 Pro Preview"), ("databricks-glm-5-2", "GLM-5.2"), diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 8001a89ac..34e0e049d 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -3,8 +3,8 @@ mod agent; pub mod auth; mod builtin; pub mod catalog; -pub(crate) mod databricks_model_names; pub mod config; +pub(crate) mod databricks_model_names; mod handoff; mod hints; mod llm; diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 619241794..9e8009ad9 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -148,5 +148,5 @@ treat a config-behavior diff without a matching AGENTS.md diff (or an explicit Labels shown in cards, pickers, and popovers follow a three-tier cascade: 1. **API/runtime `name`** — `AgentModelInfo.name` from discovery (`AgentModelsResponse`). This is the authoritative source; all providers populate it at discover time (`openai_model_display_name`, Anthropic `display_name`, ACP runtime name, Databricks registry lookup). -2. **Table-backed fallback** — for persisted raw Databricks endpoint IDs that render before discovery data is available, `databricksModelName(id)` in `desktop/src/features/agents/lib/databricksModelNames.ts` does a static lookup against the models.dev-seeded registry. Refresh by rerunning `scripts/generate-databricks-model-names.py`. +2. **Table-backed fallback** — for persisted raw Databricks endpoint IDs that render before discovery data is available, `resolveModelLabel(id)` in `desktop/src/features/agents/lib/formatAgentModelLabel.ts` does a static lookup against the models.dev-seeded registry (`databricksModelNames.ts`). Both the Rust (`crates/buzz-agent/src/databricks_model_names.rs`) and TypeScript (`desktop/src/features/agents/lib/databricksModelNames.ts`) registries are emitted together by `scripts/generate-databricks-model-names.py` — refresh both by rerunning `python3 scripts/generate-databricks-model-names.py`. 3. **Raw ID** — any ID not covered by tiers 1 or 2 renders unchanged. No heuristic string mangling. diff --git a/desktop/src/features/agents/lib/agentCardModelLabel.ts b/desktop/src/features/agents/lib/agentCardModelLabel.ts index 065940236..81b52b021 100644 --- a/desktop/src/features/agents/lib/agentCardModelLabel.ts +++ b/desktop/src/features/agents/lib/agentCardModelLabel.ts @@ -1,5 +1,7 @@ -import { formatAgentModelLabel } from "./formatAgentModelLabel"; -import { databricksModelName } from "./databricksModelNames"; +import { + formatAgentModelLabel, + resolveModelLabel, +} from "./formatAgentModelLabel"; import type { ManagedAgent } from "@/shared/api/types"; /** @@ -42,6 +44,6 @@ export function resolveAgentCardModelLabel(input: { export function formatDefaultModelLabel(defaultModel: string) { const model = defaultModel.trim(); return model - ? `Default model (${databricksModelName(model)})` + ? `Default model (${resolveModelLabel(model)})` : "Default model"; } diff --git a/desktop/src/features/agents/lib/databricksModelNames.test.mjs b/desktop/src/features/agents/lib/databricksModelNames.test.mjs index 119181c73..dface2f37 100644 --- a/desktop/src/features/agents/lib/databricksModelNames.test.mjs +++ b/desktop/src/features/agents/lib/databricksModelNames.test.mjs @@ -1,45 +1,125 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { DATABRICKS_MODEL_NAMES } from "./databricksModelNames.ts"; import { - databricksModelName, - DATABRICKS_MODEL_NAMES, -} from "./databricksModelNames.ts"; + resolveModelLabel, + formatAgentModelLabel, +} from "./formatAgentModelLabel.ts"; -test("databricksModelName — known managed endpoint returns curated name", () => { - assert.equal(databricksModelName("databricks-gpt-5-5"), "GPT-5.5"); +// --------------------------------------------------------------------------- +// resolveModelLabel — known IDs → curated names +// --------------------------------------------------------------------------- + +test("resolveModelLabel — known managed endpoint returns curated name", () => { + assert.equal(resolveModelLabel("databricks-gpt-5-5"), "GPT-5.5"); assert.equal( - databricksModelName("databricks-claude-opus-4-7"), + resolveModelLabel("databricks-claude-opus-4-7"), "Claude Opus 4.7", ); - assert.equal(databricksModelName("databricks-gpt-oss-120b"), "GPT OSS 120B"); + assert.equal(resolveModelLabel("databricks-gpt-oss-120b"), "GPT OSS 120B"); }); -test("databricksModelName — unknown custom endpoint returns raw ID unchanged", () => { - // A workspace-specific or date-stamped endpoint that isn't in the registry - // must never be guessed — return it verbatim. +// --------------------------------------------------------------------------- +// resolveModelLabel — unknown/custom IDs must pass through unchanged +// --------------------------------------------------------------------------- + +test("resolveModelLabel — unknown custom endpoint returns raw ID unchanged", () => { assert.equal( - databricksModelName("databricks-team-2025-01"), + resolveModelLabel("databricks-team-2025-01"), "databricks-team-2025-01", ); assert.equal( - databricksModelName("databricks-finance-2025-01-30"), + resolveModelLabel("databricks-finance-2025-01-30"), "databricks-finance-2025-01-30", ); assert.equal( - databricksModelName("some-custom-workspace-model"), + resolveModelLabel("some-custom-workspace-model"), "some-custom-workspace-model", ); }); -test("databricksModelName — empty string returns empty string", () => { - assert.equal(databricksModelName(""), ""); +// --------------------------------------------------------------------------- +// resolveModelLabel — Object.prototype key hole: must not resolve through prototype +// --------------------------------------------------------------------------- + +test("resolveModelLabel — 'constructor' passes through as raw ID", () => { + assert.equal(resolveModelLabel("constructor"), "constructor"); }); -test("DATABRICKS_MODEL_NAMES — registry is non-empty and all values are non-empty strings", () => { - const entries = Object.entries(DATABRICKS_MODEL_NAMES); - assert.ok(entries.length > 0, "registry must not be empty"); - for (const [id, name] of entries) { +test("resolveModelLabel — '__proto__' passes through as raw ID", () => { + assert.equal(resolveModelLabel("__proto__"), "__proto__"); +}); + +test("resolveModelLabel — 'toString' passes through as raw ID", () => { + assert.equal(resolveModelLabel("toString"), "toString"); +}); + +test("resolveModelLabel — 'hasOwnProperty' passes through as raw ID", () => { + assert.equal(resolveModelLabel("hasOwnProperty"), "hasOwnProperty"); +}); + +// --------------------------------------------------------------------------- +// resolveModelLabel — discovered name takes precedence over registry and raw ID +// --------------------------------------------------------------------------- + +test("resolveModelLabel — nonblank discoveredName wins over registry entry", () => { + // Even for a known registry ID, a nonblank discovered name wins (tier 1). + assert.equal( + resolveModelLabel("databricks-gpt-5-5", "My Custom Name"), + "My Custom Name", + ); +}); + +test("resolveModelLabel — nonblank discoveredName wins over unknown raw ID", () => { + assert.equal( + resolveModelLabel("databricks-team-2025-01", "Team Model"), + "Team Model", + ); +}); + +test("resolveModelLabel — blank/null discoveredName falls back to registry then raw ID", () => { + assert.equal(resolveModelLabel("databricks-gpt-5-5", null), "GPT-5.5"); + assert.equal(resolveModelLabel("databricks-gpt-5-5", ""), "GPT-5.5"); + assert.equal(resolveModelLabel("databricks-gpt-5-5", " "), "GPT-5.5"); + assert.equal( + resolveModelLabel("databricks-team-2025-01", null), + "databricks-team-2025-01", + ); +}); + +test("resolveModelLabel — empty id returns empty string", () => { + assert.equal(resolveModelLabel(""), ""); +}); + +// --------------------------------------------------------------------------- +// formatAgentModelLabel — null/empty → "Auto", non-empty → resolveModelLabel +// --------------------------------------------------------------------------- + +test("formatAgentModelLabel — null or empty returns Auto", () => { + assert.equal(formatAgentModelLabel(null), "Auto"); + assert.equal(formatAgentModelLabel(""), "Auto"); + assert.equal(formatAgentModelLabel(" "), "Auto"); +}); + +test("formatAgentModelLabel — known Databricks ID returns curated name", () => { + assert.equal(formatAgentModelLabel("databricks-gpt-5-5"), "GPT-5.5"); +}); + +test("formatAgentModelLabel — unknown custom Databricks ID returns raw ID unchanged", () => { + assert.equal( + formatAgentModelLabel("databricks-team-2025-01"), + "databricks-team-2025-01", + ); +}); + +// --------------------------------------------------------------------------- +// DATABRICKS_MODEL_NAMES Map — structural invariants +// --------------------------------------------------------------------------- + +test("DATABRICKS_MODEL_NAMES — registry is non-empty and all entries are valid", () => { + assert.ok(DATABRICKS_MODEL_NAMES.size > 0, "registry must not be empty"); + for (const [id, name] of DATABRICKS_MODEL_NAMES.entries()) { assert.ok( id.startsWith("databricks-"), `ID ${id} must start with 'databricks-'`, @@ -48,3 +128,33 @@ test("DATABRICKS_MODEL_NAMES — registry is non-empty and all values are non-em assert.notEqual(name, id, `curated name for ${id} must differ from raw ID`); } }); + +test("DATABRICKS_MODEL_NAMES — is a Map (not a plain object — prototype-key safety)", () => { + assert.ok( + DATABRICKS_MODEL_NAMES instanceof Map, + "must be a Map, not a plain object", + ); +}); + +// --------------------------------------------------------------------------- +// Rust/TS parity: spot-check representative entries from the generated Rust slice +// The generator emits both files from the same source, so any key present in Rust +// must also be present in the TS Map with the same value. +// --------------------------------------------------------------------------- + +test("DATABRICKS_MODEL_NAMES — parity spot-check: representative entries match Rust slice values", () => { + const expected = [ + ["databricks-gpt-5-5", "GPT-5.5"], + ["databricks-claude-opus-4-7", "Claude Opus 4.7"], + ["databricks-gpt-oss-120b", "GPT OSS 120B"], + ["databricks-claude-sonnet-4-5", "Claude Sonnet 4.5 (latest)"], + ["databricks-gemini-2-5-flash", "Gemini 2.5 Flash"], + ]; + for (const [id, name] of expected) { + assert.equal( + DATABRICKS_MODEL_NAMES.get(id), + name, + `TS registry entry for '${id}' must match Rust registry`, + ); + } +}); diff --git a/desktop/src/features/agents/lib/databricksModelNames.ts b/desktop/src/features/agents/lib/databricksModelNames.ts index 42f443bf9..f0da27357 100644 --- a/desktop/src/features/agents/lib/databricksModelNames.ts +++ b/desktop/src/features/agents/lib/databricksModelNames.ts @@ -1,6 +1,6 @@ // GENERATED by scripts/generate-databricks-model-names.py // Source: https://models.dev/api.json -- providers.databricks.models -// Refresh: python3 scripts/generate-databricks-model-names.py (then port to TS) +// Refresh: python3 scripts/generate-databricks-model-names.py // // Do not hand-edit -- rerun the script to update. @@ -10,45 +10,38 @@ * Keys are endpoint IDs returned verbatim by the discovery APIs. * Values are human-readable display names sourced from models.dev. * - * Unknown endpoint IDs are displayed as their raw ID -- no guessing. + * Unknown endpoint IDs are resolved by resolveModelLabel() as raw IDs. + * Use a Map to avoid Object.prototype key collisions. */ -export const DATABRICKS_MODEL_NAMES: Record = { - "databricks-claude-haiku-4-5": "Claude Haiku 4.5 (latest)", - "databricks-claude-opus-4-1": "Claude Opus 4.1 (latest)", - "databricks-claude-opus-4-5": "Claude Opus 4.5 (latest)", - "databricks-claude-opus-4-6": "Claude Opus 4.6", - "databricks-claude-opus-4-7": "Claude Opus 4.7", - "databricks-claude-sonnet-4": "Claude Sonnet 4.5", - "databricks-claude-sonnet-4-5": "Claude Sonnet 4.5 (latest)", - "databricks-claude-sonnet-4-6": "Claude Sonnet 4.6", - "databricks-gemini-2-5-flash": "Gemini 2.5 Flash", - "databricks-gemini-2-5-pro": "Gemini 2.5 Pro", - "databricks-gemini-3-1-flash-lite": "Gemini 3.1 Flash Lite Preview", - "databricks-gemini-3-1-pro": "Gemini 3.1 Pro Preview Custom Tools", - "databricks-gemini-3-flash": "Gemini 3 Flash Preview", - "databricks-gemini-3-pro": "Gemini 3 Pro Preview", - "databricks-glm-5-2": "GLM-5.2", - "databricks-gpt-5": "GPT-5", - "databricks-gpt-5-1": "GPT-5.1", - "databricks-gpt-5-2": "GPT-5.2", - "databricks-gpt-5-4": "GPT-5.4", - "databricks-gpt-5-4-mini": "GPT-5.4 mini", - "databricks-gpt-5-4-nano": "GPT-5.4 nano", - "databricks-gpt-5-5": "GPT-5.5", - "databricks-gpt-5-6-luna": "GPT-5.6 Luna", - "databricks-gpt-5-6-sol": "GPT-5.6 Sol", - "databricks-gpt-5-6-terra": "GPT-5.6 Terra", - "databricks-gpt-5-mini": "GPT-5 Mini", - "databricks-gpt-5-nano": "GPT-5 Nano", - "databricks-gpt-oss-120b": "GPT OSS 120B", - "databricks-gpt-oss-20b": "GPT OSS 20B", - "databricks-kimi-k2-7-code": "Kimi K2.7 Code", -}; - -/** - * Returns the curated display name for a Databricks endpoint ID, or the raw - * ID when no entry exists in the registry. No heuristic guessing. - */ -export function databricksModelName(id: string): string { - return DATABRICKS_MODEL_NAMES[id] ?? id; -} +export const DATABRICKS_MODEL_NAMES: Map = new Map([ + ["databricks-claude-haiku-4-5", "Claude Haiku 4.5 (latest)"], + ["databricks-claude-opus-4-1", "Claude Opus 4.1 (latest)"], + ["databricks-claude-opus-4-5", "Claude Opus 4.5 (latest)"], + ["databricks-claude-opus-4-6", "Claude Opus 4.6"], + ["databricks-claude-opus-4-7", "Claude Opus 4.7"], + ["databricks-claude-sonnet-4", "Claude Sonnet 4.5"], + ["databricks-claude-sonnet-4-5", "Claude Sonnet 4.5 (latest)"], + ["databricks-claude-sonnet-4-6", "Claude Sonnet 4.6"], + ["databricks-gemini-2-5-flash", "Gemini 2.5 Flash"], + ["databricks-gemini-2-5-pro", "Gemini 2.5 Pro"], + ["databricks-gemini-3-1-flash-lite", "Gemini 3.1 Flash Lite Preview"], + ["databricks-gemini-3-1-pro", "Gemini 3.1 Pro Preview Custom Tools"], + ["databricks-gemini-3-flash", "Gemini 3 Flash Preview"], + ["databricks-gemini-3-pro", "Gemini 3 Pro Preview"], + ["databricks-glm-5-2", "GLM-5.2"], + ["databricks-gpt-5", "GPT-5"], + ["databricks-gpt-5-1", "GPT-5.1"], + ["databricks-gpt-5-2", "GPT-5.2"], + ["databricks-gpt-5-4", "GPT-5.4"], + ["databricks-gpt-5-4-mini", "GPT-5.4 mini"], + ["databricks-gpt-5-4-nano", "GPT-5.4 nano"], + ["databricks-gpt-5-5", "GPT-5.5"], + ["databricks-gpt-5-6-luna", "GPT-5.6 Luna"], + ["databricks-gpt-5-6-sol", "GPT-5.6 Sol"], + ["databricks-gpt-5-6-terra", "GPT-5.6 Terra"], + ["databricks-gpt-5-mini", "GPT-5 Mini"], + ["databricks-gpt-5-nano", "GPT-5 Nano"], + ["databricks-gpt-oss-120b", "GPT OSS 120B"], + ["databricks-gpt-oss-20b", "GPT OSS 20B"], + ["databricks-kimi-k2-7-code", "Kimi K2.7 Code"], +]); diff --git a/desktop/src/features/agents/lib/formatAgentModelLabel.ts b/desktop/src/features/agents/lib/formatAgentModelLabel.ts index 46ad5897e..e0595e4f6 100644 --- a/desktop/src/features/agents/lib/formatAgentModelLabel.ts +++ b/desktop/src/features/agents/lib/formatAgentModelLabel.ts @@ -1,8 +1,30 @@ -import { databricksModelName } from "./databricksModelNames"; +import { DATABRICKS_MODEL_NAMES } from "./databricksModelNames"; /** - * Returns a human-readable model label for an agent or persona, falling back to - * "Auto" when no model is set (empty or whitespace-only). + * Resolves a human-readable label for a model, following the three-tier + * precedence documented in AGENTS.md: + * + * 1. Nonblank discovered/API name (e.g. from AgentModelInfo.name) + * 2. Registry lookup by ID (models.dev-seeded Databricks table) + * 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". + */ +export function resolveModelLabel( + id: string, + discoveredName?: string | null | undefined, +): string { + const trimmedName = discoveredName?.trim(); + if (trimmedName) return trimmedName; + const trimmedId = id.trim(); + if (!trimmedId) return ""; + 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 @@ -11,5 +33,5 @@ import { databricksModelName } from "./databricksModelNames"; export function formatAgentModelLabel(model: string | null | undefined) { const trimmed = model?.trim(); if (!trimmed) return "Auto"; - return databricksModelName(trimmed); + return resolveModelLabel(trimmed); } diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 1bd8af897..9f5e21085 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -44,6 +44,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, @@ -776,7 +777,9 @@ export function AgentConfigFields({ {runtimeSource ? {runtimeSource} : null} - {agent.model ? {databricksModelName(agent.model)} : null} + {agent.model ? {resolveModelLabel(agent.model)} : null} ) : null} diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index f7bafde99..258198339 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -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) + : modelsData?.agentDefaultModel + ? `${resolveModelLabel(modelsData.agentDefaultModel)} (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({
{agent.model ? ( <> -

{agent.model}

+

+ {resolveModelLabel(agent.model)} +

This runtime does not support switching models.

diff --git a/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts b/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts index e7b434288..5450910b1 100644 --- a/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts +++ b/desktop/src/features/agents/ui/usePersonaModelDiscovery.ts @@ -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)})` : "Default model", }, ]; diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index d084d41e0..2c76fdc79 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -51,7 +51,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 { databricksModelName } from "@/features/agents/lib/databricksModelNames"; +import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel"; type UserProfilePopoverProps = { children: React.ReactNode; @@ -612,7 +612,7 @@ export function UserProfilePopover({ {runtimeLabel(relayAgent.agentType)} ) : null} {managedAgent?.model ? ( - {databricksModelName(managedAgent.model)} + {resolveModelLabel(managedAgent.model)} ) : null} {managedAgent?.acpCommand ? ( ACP: {managedAgent.acpCommand} diff --git a/scripts/generate-databricks-model-names.py b/scripts/generate-databricks-model-names.py index 069259ffa..cb0437edb 100755 --- a/scripts/generate-databricks-model-names.py +++ b/scripts/generate-databricks-model-names.py @@ -1,55 +1,80 @@ #!/usr/bin/env python3 -"""Generate crates/buzz-agent/src/databricks_model_names.rs from models.dev. +"""Generate Databricks model-name registries from models.dev. -Usage: - python3 scripts/generate-databricks-model-names.py \ - > crates/buzz-agent/src/databricks_model_names.rs +Emits two generated files in one invocation: -Fetches https://models.dev/api.json, extracts providers.databricks.models (the -authoritative curated name registry used by goose and others), and emits a -static Rust slice of (id, display_name) pairs sorted by ID. + crates/buzz-agent/src/databricks_model_names.rs (Rust) + desktop/src/features/agents/lib/databricksModelNames.ts (TypeScript) +Usage (from repo root): + python3 scripts/generate-databricks-model-names.py + +Fetches https://models.dev/api.json, extracts providers.databricks.models, +and emits sorted (id, display_name) tables formatted for each language. Re-run whenever Databricks ships a new managed endpoint and commit the diff. + +Both files are kept in sync by this script — never edit them by hand. """ import json +import re import subprocess import sys - +from pathlib import Path URL = "https://models.dev/api.json" +REPO_ROOT = Path(__file__).resolve().parent.parent +RUST_OUT = REPO_ROOT / "crates/buzz-agent/src/databricks_model_names.rs" +TS_OUT = REPO_ROOT / "desktop/src/features/agents/lib/databricksModelNames.ts" + +# Allowed characters in endpoint IDs and curated names. +SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9.\-]*$") +SAFE_NAME_RE = re.compile(r"^[^\x00-\x1f\"\\<>&]*$") def fetch(url: str) -> bytes: - # urllib blocks with HTTP 403 without a browser User-Agent; use curl when - # available so the script works in hermit environments where curl is pinned. + """Fetch URL via curl; raise on HTTP error.""" result = subprocess.run( - ["curl", "-s", "-A", "Mozilla/5.0", url], + ["curl", "--fail", "--silent", "--max-time", "30", "-A", "Mozilla/5.0", url], capture_output=True, - check=True, ) + if result.returncode != 0: + raise RuntimeError( + f"curl failed (exit {result.returncode}): {result.stderr.decode()}" + ) return result.stdout +def validate_entries( + entries: list[tuple[str, str]], +) -> list[tuple[str, str]]: + """Validate all (id, name) pairs and raise on unexpected shapes.""" + for id_, name in entries: + if not isinstance(id_, str) or not isinstance(name, str): + raise ValueError(f"Non-string entry: {id_!r} -> {name!r}") + if not SAFE_ID_RE.match(id_): + raise ValueError(f"Unsafe endpoint ID: {id_!r}") + if not SAFE_NAME_RE.match(name): + raise ValueError(f"Unsafe display name for {id_!r}: {name!r}") + return entries + + def rust_str(s: str) -> str: - """Emit a Rust string literal with double quotes.""" - escaped = s.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' + """Emit a double-quoted Rust string literal (backslash + quote only).""" + return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' -def main() -> None: - raw = fetch(URL) - data = json.loads(raw) - models: dict = data["databricks"]["models"] - entries = sorted( - (k, v["name"] if isinstance(v, dict) else k) for k, v in models.items() - ) +def ts_str(s: str) -> str: + """Emit a double-quoted TypeScript string literal.""" + return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"' + +def write_rust(entries: list[tuple[str, str]]) -> None: + """Write the Rust generated file, then rustfmt it for byte-for-byte stability.""" lines = [ "// GENERATED by scripts/generate-databricks-model-names.py", "// Source: https://models.dev/api.json -- providers.databricks.models", - "// Refresh: python3 scripts/generate-databricks-model-names.py \\", - "// > crates/buzz-agent/src/databricks_model_names.rs", + "// Refresh: python3 scripts/generate-databricks-model-names.py", "//", "// Do not hand-edit -- rerun the script to update.", "", @@ -64,9 +89,87 @@ def main() -> None: for id_, name in entries: lines.append(f" ({rust_str(id_)}, {rust_str(name)}),") lines += ["];", ""] + RUST_OUT.write_text("\n".join(lines)) + # Run rustfmt so the committed file is always formatter-clean and + # a subsequent generator run reproduces it byte-for-byte. + result = subprocess.run( + ["cargo", "fmt", "--", str(RUST_OUT)], + cwd=REPO_ROOT, + capture_output=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"rustfmt failed: {result.stderr.decode()}" + ) + print(f"Wrote {RUST_OUT.relative_to(REPO_ROOT)}") - sys.stdout.write("\n".join(lines)) + +def write_ts(entries: list[tuple[str, str]]) -> None: + """Write the TypeScript generated file, then biome-format it.""" + lines = [ + "// GENERATED by scripts/generate-databricks-model-names.py", + "// Source: https://models.dev/api.json -- providers.databricks.models", + "// Refresh: python3 scripts/generate-databricks-model-names.py", + "//", + "// Do not hand-edit -- rerun the script to update.", + "", + "/**", + " * Curated display names for known Databricks AI Gateway endpoints.", + " *", + " * Keys are endpoint IDs returned verbatim by the discovery APIs.", + " * Values are human-readable display names sourced from models.dev.", + " *", + " * Unknown endpoint IDs are resolved by resolveModelLabel() as raw IDs.", + " * Use a Map to avoid Object.prototype key collisions.", + " */", + "export const DATABRICKS_MODEL_NAMES: Map = new Map([", + ] + for id_, name in entries: + lines.append(f" [{ts_str(id_)}, {ts_str(name)}],") + lines += ["]);\n"] + TS_OUT.write_text("\n".join(lines)) + # biome format for byte-for-byte stability on regenerate. + desktop_dir = REPO_ROOT / "desktop" + biome_bin = desktop_dir / "node_modules/.bin/biome" + if biome_bin.exists(): + result = subprocess.run( + [str(biome_bin), "format", "--write", str(TS_OUT)], + cwd=desktop_dir, + capture_output=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"biome format failed: {result.stderr.decode()}" + ) + print(f"Wrote {TS_OUT.relative_to(REPO_ROOT)}") + + +def main() -> None: + raw = fetch(URL) + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + raise RuntimeError(f"models.dev response is not valid JSON: {e}") from e + + if "databricks" not in data or "models" not in data["databricks"]: + raise RuntimeError( + "Unexpected models.dev shape: missing data['databricks']['models']" + ) + + models: dict = data["databricks"]["models"] + raw_entries = sorted( + (k, v["name"] if isinstance(v, dict) else k) for k, v in models.items() + ) + entries = validate_entries(raw_entries) + + write_rust(entries) + write_ts(entries) + print(f"Done — {len(entries)} Databricks endpoints.") if __name__ == "__main__": - main() + try: + main() + except Exception as exc: # noqa: BLE001 + print(f"error: {exc}", file=sys.stderr) + sys.exit(1)