Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/buzz-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| `OPENAI_COMPAT_MODEL` | — | Required when provider=openai. |
| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, OpenRouter, Ollama, etc. |
| `OPENAI_COMPAT_API` | `auto` | `auto` \| `chat` \| `responses`. `auto` picks Responses for `*.openai.com`, Chat Completions everywhere else. |
| `BUZZ_AGENT_SERVICE_TIER` | — | Optional OpenAI processing tier: `auto`, `default`, `flex`, or `priority`. |
| `DATABRICKS_HOST` | — | Required when provider=databricks or provider=databricks_v2. |
| `DATABRICKS_MODEL` | — | Required when provider=databricks or provider=databricks_v2. |
| `DATABRICKS_TOKEN` | — | Optional static bearer escape hatch. If unset, Databricks uses browser OAuth + refresh cache. |
Expand Down
82 changes: 82 additions & 0 deletions crates/buzz-agent/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,27 @@ pub enum OpenAiApi {
Auto,
}

/// OpenAI request processing tier. `None` leaves the provider default intact.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenAiServiceTier {
Auto,
Default,
Flex,
Priority,
}

impl OpenAiServiceTier {
/// Return the wire-format value expected by OpenAI APIs.
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Default => "default",
Self::Flex => "flex",
Self::Priority => "priority",
}
}
}

#[derive(Debug, Clone)]
pub struct Config {
pub provider: Provider,
Expand Down Expand Up @@ -730,6 +751,8 @@ pub struct Config {
/// Thinking/reasoning effort level. `None` = use provider default (no
/// thinking config sent). Set via `BUZZ_AGENT_THINKING_EFFORT`.
pub thinking_effort: Option<ThinkingEffort>,
/// OpenAI processing tier. Set via `BUZZ_AGENT_SERVICE_TIER`.
pub service_tier: Option<OpenAiServiceTier>,
}

impl Config {
Expand Down Expand Up @@ -824,6 +847,10 @@ impl Config {
hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"),
hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0,
thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?,
service_tier: parse_service_tier_for_provider(
provider,
env("BUZZ_AGENT_SERVICE_TIER").as_deref(),
)?,
};
cfg.validate()?;
Ok(cfg)
Expand Down Expand Up @@ -864,6 +891,7 @@ impl Config {
hook_servers: HookServers::None,
hints_enabled: false,
thinking_effort: None,
service_tier: None,
}
}

Expand Down Expand Up @@ -951,6 +979,33 @@ impl Config {
}
}

/// Parse the optional OpenAI service tier. Empty values preserve provider defaults.
pub fn parse_service_tier(raw: Option<&str>) -> Result<Option<OpenAiServiceTier>, String> {
match raw.map(str::trim).filter(|value| !value.is_empty()) {
None => Ok(None),
Some(value) => match value.to_ascii_lowercase().as_str() {
"auto" => Ok(Some(OpenAiServiceTier::Auto)),
"default" => Ok(Some(OpenAiServiceTier::Default)),
"flex" => Ok(Some(OpenAiServiceTier::Flex)),
"priority" => Ok(Some(OpenAiServiceTier::Priority)),
Comment on lines +987 to +990

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept the Scale service tier

OpenAI's API reference lists scale as a valid service_tier request value alongside auto, default, flex, and priority (see https://developers.openai.com/api/reference/resources/chat). With this allowlist, an operator on Scale Tier who sets BUZZ_AGENT_SERVICE_TIER=scale gets a startup config error before any request is made, and the matching desktop selector cannot offer the valid tier either.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request values are auto, default, flex, and priority. Scale Tier is organization/project capacity, not a per-request selector.

OpenAI uses Scale capacity automatically when the project is Scale-enabled and the request uses auto or omits service_tier. We are therefore leaving scale out of the parser and UI.

other => Err(format!(
"config: BUZZ_AGENT_SERVICE_TIER={other} not supported (use auto|default|flex|priority)"
)),
},
}
}

fn parse_service_tier_for_provider(
provider: Provider,
raw: Option<&str>,
) -> Result<Option<OpenAiServiceTier>, String> {
if matches!(provider, Provider::OpenAi) {
parse_service_tier(raw)
} else {
Ok(None)
}
}

fn env(k: &str) -> Option<String> {
std::env::var(k).ok()
}
Expand Down Expand Up @@ -1347,6 +1402,33 @@ mod tests {
);
}

#[test]
fn parse_service_tier_accepts_openai_values_case_insensitively() {
assert_eq!(
parse_service_tier(Some(" FLEX ")).unwrap(),
Some(OpenAiServiceTier::Flex)
);
assert_eq!(
parse_service_tier(Some("priority")).unwrap(),
Some(OpenAiServiceTier::Priority)
);
assert_eq!(parse_service_tier(Some(" ")).unwrap(), None);
}

#[test]
fn parse_service_tier_is_ignored_for_non_openai_providers() {
assert_eq!(
parse_service_tier_for_provider(Provider::Anthropic, Some("invalid")).unwrap(),
None
);
}

#[test]
fn parse_service_tier_rejects_unknown_value() {
let err = parse_service_tier(Some("economy")).unwrap_err();
assert!(err.contains("BUZZ_AGENT_SERVICE_TIER=economy"), "{err}");
}

#[test]
fn thinking_effort_anthropic_budget_tokens_mapping() {
assert_eq!(ThinkingEffort::Low.anthropic_budget_tokens(), 1_024);
Expand Down
65 changes: 44 additions & 21 deletions crates/buzz-agent/src/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,28 +183,26 @@ impl Llm {
let r = self
.openai_request(cfg, effective_model, |use_responses| {
if use_responses {
(
json!({
"model": effective_model,
"max_output_tokens": max_output_tokens,
"instructions": system_prompt,
"input": user_prompt,
}),
parse_responses as OpenAiParse,
)
let mut body = json!({
"model": effective_model,
"max_output_tokens": max_output_tokens,
"instructions": system_prompt,
"input": user_prompt,
});
apply_openai_service_tier(cfg, &mut body);
(body, parse_responses as OpenAiParse)
} else {
(
json!({
"model": effective_model,
"stream": false,
"max_completion_tokens": max_output_tokens,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": user_prompt },
],
}),
parse_openai as OpenAiParse,
)
let mut body = json!({
"model": effective_model,
"stream": false,
"max_completion_tokens": max_output_tokens,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": user_prompt },
],
});
apply_openai_service_tier(cfg, &mut body);
(body, parse_openai as OpenAiParse)
}
})
.await?;
Expand Down Expand Up @@ -545,6 +543,7 @@ fn openai_body(
if let Some(e) = effort {
body["reasoning_effort"] = json!(e.openai_effort_str());
}
apply_openai_service_tier(cfg, &mut body);
if !tools_json.is_empty() {
body["tools"] = Value::Array(tools_json);
body["tool_choice"] = json!("auto");
Expand Down Expand Up @@ -664,13 +663,20 @@ fn responses_body(
if let Some(e) = effort {
body["reasoning"] = json!({ "effort": e.openai_effort_str() });
}
apply_openai_service_tier(cfg, &mut body);
if !tools_json.is_empty() {
body["tools"] = Value::Array(tools_json);
body["tool_choice"] = json!("auto");
}
body
}

fn apply_openai_service_tier(cfg: &Config, body: &mut Value) {
if let (Provider::OpenAi, Some(tier)) = (cfg.provider, cfg.service_tier) {
body["service_tier"] = json!(tier.as_str());
}
}

/// Narrow matcher for "you should be on the Responses API" provider errors,
/// the signal we use to auto-upgrade. Triggers on the literal path
/// `/v1/responses` (Databricks GPT-5.5 phrasing) or the prose
Expand Down Expand Up @@ -1251,6 +1257,7 @@ mod tests {
openai_api: OpenAiApi::Chat,
hints_enabled: true,
thinking_effort: None,
service_tier: None,
}
}

Expand Down Expand Up @@ -1846,6 +1853,22 @@ mod tests {
);
}

#[test]
fn openai_body_emits_service_tier_when_configured() {
let mut cfg = cfg(Provider::OpenAi);
cfg.service_tier = Some(crate::config::OpenAiServiceTier::Flex);
let body = openai_body(&cfg, "system", &[], &[], "model", None);
assert_eq!(body["service_tier"], "flex");
}

#[test]
fn responses_body_emits_service_tier_when_configured() {
let mut cfg = cfg(Provider::OpenAi);
cfg.service_tier = Some(crate::config::OpenAiServiceTier::Priority);
let body = responses_body(&cfg, "system", &[], &[], "model", None);
assert_eq!(body["service_tier"], "priority");
}

#[test]
fn openai_body_emits_reasoning_effort_medium() {
let body = openai_body(
Expand Down
20 changes: 20 additions & 0 deletions desktop/src/features/agents/ui/buzzAgentConfig.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
BUZZ_AGENT_MAX_CONTEXT_TOKENS,
BUZZ_AGENT_MAX_OUTPUT_TOKENS,
BUZZ_AGENT_MAX_ROUNDS,
BUZZ_AGENT_SERVICE_TIER,
BUZZ_AGENT_SERVICE_TIER_VALUES,
BUZZ_AGENT_THINKING_EFFORT,
BUZZ_AGENT_THINKING_EFFORT_VALUES,
getProviderEffortConfig,
Expand Down Expand Up @@ -36,6 +38,16 @@ test("env var key constants match expected BUZZ_AGENT_* names", () => {
assert.equal(BUZZ_AGENT_MAX_OUTPUT_TOKENS, "BUZZ_AGENT_MAX_OUTPUT_TOKENS");
assert.equal(BUZZ_AGENT_MAX_CONTEXT_TOKENS, "BUZZ_AGENT_MAX_CONTEXT_TOKENS");
assert.equal(BUZZ_AGENT_MAX_ROUNDS, "BUZZ_AGENT_MAX_ROUNDS");
assert.equal(BUZZ_AGENT_SERVICE_TIER, "BUZZ_AGENT_SERVICE_TIER");
});

test("BUZZ_AGENT_SERVICE_TIER_VALUES contains the OpenAI service tiers", () => {
assert.deepEqual([...BUZZ_AGENT_SERVICE_TIER_VALUES], [
"auto",
"default",
"flex",
"priority",
]);
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -129,6 +141,14 @@ test("clearing max rounds removes the key", () => {
assert.equal(Object.hasOwn(result, BUZZ_AGENT_MAX_ROUNDS), false);
});

test("setting and clearing service tier uses the inheritable env-var shape", () => {
const set = applyEnvVarChange({}, BUZZ_AGENT_SERVICE_TIER, "flex");
assert.equal(set[BUZZ_AGENT_SERVICE_TIER], "flex");

const cleared = applyEnvVarChange(set, BUZZ_AGENT_SERVICE_TIER, "");
assert.equal(Object.hasOwn(cleared, BUZZ_AGENT_SERVICE_TIER), false);
});

test("changing one field does not disturb other env vars", () => {
const initial = {
SOME_OTHER_KEY: "value",
Expand Down
11 changes: 11 additions & 0 deletions desktop/src/features/agents/ui/buzzAgentConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ export const BUZZ_AGENT_MAX_CONTEXT_TOKENS = "BUZZ_AGENT_MAX_CONTEXT_TOKENS";
/** Env var key for the maximum number of LLM/tool rounds per turn. */
export const BUZZ_AGENT_MAX_ROUNDS = "BUZZ_AGENT_MAX_ROUNDS";

/** Env var key for the OpenAI request processing tier. */
export const BUZZ_AGENT_SERVICE_TIER = "BUZZ_AGENT_SERVICE_TIER";

/** OpenAI service tiers accepted by buzz-agent. */
export const BUZZ_AGENT_SERVICE_TIER_VALUES = [
"auto",
"default",
"flex",
"priority",
Comment on lines +23 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route service-tier fields through the catalog

This adds the service-tier capability as a frontend-only env key/value table, with rendering gated separately in the component, but the runtime catalog/core never exposes whether the selected harness supports BUZZ_AGENT_SERVICE_TIER. That leaves the shared defaults/onboarding renderer and the documented reset/absence policies unaware of the new field; add the capability to KnownAcpRuntime/AcpRuntimeCatalogEntry and project it through deriveAgentConfigFieldModel instead of maintaining a separate TypeScript table.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defaults/onboarding catalog integration would be a broader configuration-model change, so we are leaving that for a separate follow-up rather than expanding this PR.

] as const;

/**
* Ordered set of valid thinking-effort values accepted by buzz-agent.
* Mirrors `parse_thinking_effort` in `crates/buzz-agent/src/config.rs`.
Expand Down
38 changes: 38 additions & 0 deletions desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
BUZZ_AGENT_MAX_CONTEXT_TOKENS,
BUZZ_AGENT_MAX_OUTPUT_TOKENS,
BUZZ_AGENT_MAX_ROUNDS,
BUZZ_AGENT_SERVICE_TIER,
BUZZ_AGENT_SERVICE_TIER_VALUES,
BUZZ_AGENT_THINKING_EFFORT,
BUZZ_AGENT_THINKING_EFFORT_VALUES,
getProviderEffortConfig,
Expand Down Expand Up @@ -221,6 +223,7 @@ export function BuzzAgentModelTuningFields({
effortConfig;

const currentEffort = envVars[BUZZ_AGENT_THINKING_EFFORT] ?? "";
const isOpenAi = provider === "openai";
useEffortAutoClear({
currentEffort,
effortValid,
Expand Down Expand Up @@ -258,6 +261,41 @@ export function BuzzAgentModelTuningFields({
</p>
</div>

{isOpenAi && (
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="ba-service-tier">
Service tier
</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs"
data-testid="ba-service-tier-select"
id="ba-service-tier"
onChange={(event) =>
onEnvVarChange(BUZZ_AGENT_SERVICE_TIER, event.target.value)
}
value={envVars[BUZZ_AGENT_SERVICE_TIER] ?? ""}
>
<option value="">
{inheritedEnvVars[BUZZ_AGENT_SERVICE_TIER]
? `Inherit (${inheritedEnvVars[BUZZ_AGENT_SERVICE_TIER]})`
: "Inherit (agent default)"}
</option>
{BUZZ_AGENT_SERVICE_TIER_VALUES.map((tier) => (
<option key={tier} value={tier}>
{tier}
</option>
))}
</select>
<p
className="text-xs text-muted-foreground"
id="help-ba-service-tier"
>
Controls OpenAI request processing priority. Leave blank to use
the project default.
</p>
</div>
)}

{/* Max Rounds */}
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="ba-max-rounds">
Expand Down