feat(agent): add direct Fireworks provider with service_tier support

Adds `Provider::Fireworks` (`BUZZ_AGENT_PROVIDER=fireworks`) calling
api.fireworks.ai directly rather than through OpenRouter, plus
`FIREWORKS_SERVICE_TIER` for its serving tier.

This is deliberately a new variant rather than an OPENAI_COMPAT_BASE_URL
override, because the wire contract differs in two ways that break the
shared OpenAI route (both measured against the live API):

1. Fireworks rejects unknown fields with 400 "Extra inputs are not
   permitted" instead of ignoring them. `openai_body` replays every
   unmodelled tool-call key through `ToolCall::provider_extra` -- which
   exists so Gemini gets its `thoughtSignature` back -- and Fireworks'
   own tool-call responses carry `index` and `name`. Without the strip,
   turn two of any tool-using session 400s on a key Fireworks itself
   sent us, after the trial has already paid for container setup.
2. Its reasoning enum is low|medium|high|xhigh|max|none|adaptive. The
   OpenAI route's per-model normalization clamps `max` to `xhigh` for
   anything but gpt-5.6, which would silently downgrade a max-effort
   benchmark cell into a condition nobody asked for.

`service_tier` is the motive for the route. On OpenRouter the field is
silently discarded: a deliberately invalid value is accepted without
error and priced identically. Fireworks validates against
auto|default|flex|priority and 400s otherwise, so `priority` actually
selects a queue -- which matters because the OpenRouter path's 429s all
reported `upstream_provider_shared_pool` for the Fireworks upstream,
capacity our own key cannot influence. The value is validated at config
parse time so a typo fails at startup rather than on the first live call.

Renames `post_openrouter`/`openrouter_summary_body` to
`post_chat_completions`/`chat_summary_body`; both were already generic
bearer-auth chat-completions helpers and are now shared with this route.

Verified end-to-end by driving the binary over ACP stdio against live
Fireworks with buzz-dev-mcp: 2 tool calls, 3 LLM requests, end_turn,
zero 400s.

Also adds the endpoint config and a leaderboard manifest for the cell,
and picks up a stale desktop lockfile entry for buzz-agent's existing
`dirs` dependency that any desktop build regenerates.

Signed-off-by: Atish Patel <atish@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Atish Patel
2026-08-07 11:52:12 -05:00
co-authored by Claude Code
parent e25cbad425
commit 1cbf0bf3a2
5 changed files with 401 additions and 21 deletions
@@ -0,0 +1,79 @@
# LEADERBOARD SUBMISSION CELL -- solo deepseek-v4-flash-0731 at reasoning
# effort `max`, served by FIREWORKS DIRECTLY at the `priority` service tier.
#
# Same model and same effort as lb-tb-solo-deepseek-v4-flash-max.yaml; the only
# deliberate difference is the route. Read the header of that file first -- every
# leaderboard-validity constraint it documents (no `environment:` block, 1.0x
# timeouts, 89 tasks x 5 trials, ATIF trajectory on every passing trial) applies
# here unchanged and is not repeated.
#
# WHY A SECOND ROUTE EXISTS. The 2026-08-06 high-effort cell took 3596 `429`s
# over 20 hours, 13 of its 20 hours throttled, and the damage was not lost
# trials but eaten deadlines: buzz-acp answers a 429 with exponential requeue
# backoff, so throttled trials timed out at 57.6% against 31.2% on calm hours.
# Every one of those 429s said `limit_source: upstream_provider_shared_pool` /
# `provider_name: Fireworks` -- OpenRouter's *shared* capacity for that upstream,
# which our own key's headroom cannot influence. This cell removes the shared
# pool from the path entirely.
#
# `service_tier` IS THE POINT, AND IT ONLY WORKS ON THIS ROUTE. Measured
# 2026-08-07: OpenRouter accepts a deliberately invalid `service_tier` without
# error, prices the request identically, and echoes nothing -- it drops the
# field. Fireworks validates against `auto|default|flex|priority` and 400s on
# anything else. The tier is set per-endpoint in the endpoint config
# (`FIREWORKS_SERVICE_TIER=priority`), NOT here, because it is deployment
# config rather than part of the condition's identity -- same reasoning as the
# OpenRouter upstream pin.
#
# THE ROW LABEL IS THE PROVIDER-FACING ID. `lb_job_config.model_name` publishes
# the roster `endpoint` verbatim, so this row would read
# `accounts/fireworks/models/deepseek-v4-flash-0731` where the OpenRouter cell
# reads `deepseek/deepseek-v4-flash-0731`. That is the honest id for what ran;
# change it only with a deliberate model-naming rule, not by editing the
# endpoint string, which is the join key into the endpoint config.
#
# COST IS 1.58x THE OPENROUTER CELL, NOT COMPARABLE WITHOUT SAYING SO. Fireworks
# lists this model at $0.14/$0.028/$0.28 standard, and `priority` is a flat 1.5x
# on all three: $0.21/$0.042/$0.42. The OpenRouter cell is pinned to
# `gmicloud/fp8` at $0.133/$0.0266/$0.266. So a cost delta between these two
# rows is mostly price sheet and tier, not serving-stack efficiency -- do not
# quote one against the other without decomposing it.
schema_version: "1"
condition: lb-tb-solo-deepseek-v4-flash-max-fw
roster:
- id: solo
kind: orchestrator
role: solo
count: 1
# Fireworks' fully-qualified model id. Doubles as the key into
# endpoints/fireworks-live.json and as the published row label.
endpoint: accounts/fireworks/models/deepseek-v4-flash-0731
model_revision: deepseek/deepseek-v4-flash-20260731
prompt:
# Byte-identical to every other solo cell in the study.
path: personas/bench/solo.md
sha256: 61c9ac9dc47cbd3c987a7ee6ceb7397496834df8d3fc284c8fe6980e1d5b4ebe
generation:
# Fireworks' own effort enum is low|medium|high|xhigh|max|none|adaptive,
# so `max` reaches the wire as `max`. The OpenAI-compat route would have
# clamped it to `xhigh`; Provider::Fireworks deliberately skips that
# normalization table, which is keyed on OpenAI model names.
thinking_effort: max
prices:
accounts/fireworks/models/deepseek-v4-flash-0731:
# Fireworks serverless `priority` tier = 1.5x its standard sheet
# ($0.14/$0.028/$0.28). These are the priority numbers, because that is what
# the endpoint config asks for; running this manifest at the default tier
# would overstate the bill by 1.5x.
input_per_million_usd: 0.21
cached_input_per_million_usd: 0.042
output_per_million_usd: 0.42
# Fallback only; Fireworks reports the real cached split per request
# (`prompt_tokens_details.cached_tokens`) and the harness prefers it.
cache_read_rate: 0.0
trial_budget:
# Unchanged from the OpenRouter cell: must clear Harbor's longest task
# deadline at 1.0x or check_budget_clears_clock refuses the run.
timeout_seconds: 12000
# No `environment:` block. See the header: adding one disqualifies the run.
@@ -0,0 +1,9 @@
{
"accounts/fireworks/models/deepseek-v4-flash-0731": {
"provider": "fireworks",
"api_key_env": "FIREWORKS_API_KEY",
"env": {
"FIREWORKS_SERVICE_TIER": "priority"
}
}
}
+110 -11
View File
@@ -688,6 +688,26 @@ pub enum Provider {
DatabricksV2,
/// OpenRouter multi-provider gateway. Routes to `{base_url}/chat/completions` with bearer auth. Wire format is OpenAI-chat-compatible.
OpenRouter,
/// Fireworks AI, called directly rather than through a gateway. Routes to
/// `{base_url}/chat/completions` with bearer auth, OpenAI-chat-compatible.
///
/// It is a separate variant rather than an `OPENAI_COMPAT_BASE_URL` override
/// because the wire contract genuinely differs from OpenAI's in two ways
/// that break the shared route (both measured 2026-08-07):
///
/// 1. **Unknown fields are rejected**, not ignored — a stray key returns
/// HTTP 400 `Extra inputs are not permitted`. `openai_body` round-trips
/// every unmodelled tool-call key through `ToolCall::provider_extra`
/// (kept for Gemini's `thoughtSignature`), and Fireworks' own tool-call
/// responses carry `index` and `name`. Replaying those on the next turn
/// is a guaranteed 400, so the route strips them.
/// 2. **The reasoning enum is its own**: `low|medium|high|xhigh|max|none|
/// adaptive`. Every [`ThinkingEffort`] level maps through unchanged
/// except `Minimal`, which Fireworks rejects. The OpenAI route's
/// per-model normalization must NOT apply here — it clamps `max` to
/// `xhigh` for anything that is not gpt-5.6, which would silently
/// downgrade a benchmark cell asking for `max`.
Fireworks,
}
/// Which OpenAI-family HTTP API to call. Set via `OPENAI_COMPAT_API`
@@ -800,6 +820,17 @@ pub struct Config {
/// Set with `allow_fallbacks: false` in the request, so an unavailable pin
/// fails loudly instead of silently redefining the condition mid-run.
pub openrouter_provider_order: Vec<String>,
/// Fireworks serving tier, from `FIREWORKS_SERVICE_TIER`. `None` (unset)
/// omits the field, which Fireworks reads as its own default.
///
/// Only sent on [`Provider::Fireworks`]. Deliberately not forwarded to
/// OpenRouter: measured 2026-08-07, OpenRouter accepts a deliberately
/// invalid `service_tier` without error and prices the request identically,
/// i.e. it drops the field silently. Fireworks instead validates it against
/// `auto|default|flex|priority` and 400s on anything else — which is the
/// only reason a tier setting is worth wiring at all, since a silently
/// dropped one is indistinguishable from not asking.
pub fireworks_service_tier: Option<String>,
}
impl Config {
@@ -811,6 +842,7 @@ impl Config {
env("ANTHROPIC_API_KEY").as_deref(),
env("OPENAI_COMPAT_API_KEY").as_deref(),
env("OPENROUTER_API_KEY").as_deref(),
env("FIREWORKS_API_KEY").as_deref(),
)?;
// Universal model override — takes priority over provider-specific model
@@ -863,6 +895,19 @@ impl Config {
env_or("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
OpenAiApi::Chat, // OpenRouter uses Chat Completions only
),
Provider::Fireworks => (
req("FIREWORKS_API_KEY")?,
resolve_model(
buzz_agent_model.as_deref(),
env("FIREWORKS_MODEL").as_deref(),
)
.ok_or_else(|| "config: FIREWORKS_MODEL required".to_string())?,
env_or(
"FIREWORKS_BASE_URL",
"https://api.fireworks.ai/inference/v1",
),
OpenAiApi::Chat, // Fireworks serves Chat Completions only
),
};
let system_prompt = match (env("BUZZ_AGENT_SYSTEM_PROMPT"), env("BUZZ_AGENT_SYSTEM_PROMPT_FILE")) {
(Some(_), Some(_)) => return Err(
@@ -911,6 +956,7 @@ impl Config {
openrouter_provider_order: parse_provider_order(
env("OPENROUTER_PROVIDER_ORDER").as_deref(),
),
fireworks_service_tier: parse_service_tier(env("FIREWORKS_SERVICE_TIER").as_deref())?,
};
cfg.validate()?;
Ok(cfg)
@@ -955,6 +1001,7 @@ impl Config {
thinking_effort: None,
prompt_caching: false,
openrouter_provider_order: Vec::new(),
fireworks_service_tier: None,
}
}
@@ -1075,6 +1122,7 @@ fn resolve_provider(
anthropic_key: Option<&str>,
openai_key: Option<&str>,
openrouter_key: Option<&str>,
fireworks_key: Option<&str>,
) -> Result<Provider, String> {
match requested.map(str::trim).filter(|s| !s.is_empty()) {
Some(raw) => {
@@ -1092,6 +1140,8 @@ fn resolve_provider(
"databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2),
"openrouter" if present_nonempty(openrouter_key) => Ok(Provider::OpenRouter),
"openrouter" => Err("config: OPENROUTER_API_KEY required".into()),
"fireworks" if present_nonempty(fireworks_key) => Ok(Provider::Fireworks),
"fireworks" => Err("config: FIREWORKS_API_KEY required".into()),
_ => Err(format!(
"config: BUZZ_AGENT_PROVIDER={raw} not supported"
)),
@@ -1183,6 +1233,28 @@ fn parse_hook_servers_env(key: &str) -> HookServers {
/// Blank entries are dropped rather than passed through, so a trailing comma or
/// an accidentally-empty variable degrades to "no pin" instead of asking
/// OpenRouter to route to a provider named "".
/// Parse `FIREWORKS_SERVICE_TIER` into the Fireworks `service_tier` field.
///
/// Validated here rather than passed through so a typo fails at startup instead
/// of 400ing on the first live call — a benchmark run that dies on request one
/// has already burned its container setup. The accepted set is Fireworks' own,
/// read off its 400 response (measured 2026-08-07): `auto`, `default`, `flex`,
/// `priority`.
///
/// Unset or blank yields `None`, which omits the field entirely and lets
/// Fireworks apply its account default.
pub fn parse_service_tier(raw: Option<&str>) -> Result<Option<String>, String> {
let trimmed = raw.unwrap_or("").trim().to_ascii_lowercase();
match trimmed.as_str() {
"" => Ok(None),
"auto" | "default" | "flex" | "priority" => Ok(Some(trimmed)),
other => Err(format!(
"config: FIREWORKS_SERVICE_TIER={other} not supported \
(use auto|default|flex|priority)"
)),
}
}
pub fn parse_provider_order(raw: Option<&str>) -> Vec<String> {
raw.unwrap_or("")
.split(',')
@@ -1329,11 +1401,11 @@ mod tests {
#[test]
fn resolve_provider_keeps_requested_provider_when_token_present() {
assert_eq!(
resolve_provider(Some("anthropic"), Some("sk-ant"), None, None).unwrap(),
resolve_provider(Some("anthropic"), Some("sk-ant"), None, None, None).unwrap(),
Provider::Anthropic
);
assert_eq!(
resolve_provider(Some("openai"), None, Some("sk-openai"), None).unwrap(),
resolve_provider(Some("openai"), None, Some("sk-openai"), None, None).unwrap(),
Provider::OpenAi
);
}
@@ -1341,17 +1413,18 @@ mod tests {
#[test]
fn resolve_provider_errors_when_requested_provider_key_missing() {
// No fallback — missing key returns an error regardless of Databricks availability.
let err = resolve_provider(Some("anthropic"), None, None, None).unwrap_err();
let err = resolve_provider(Some("anthropic"), None, None, None, None).unwrap_err();
assert!(err.contains("ANTHROPIC_API_KEY required"), "{err}");
let err = resolve_provider(Some("openai-compat"), None, Some(" "), None).unwrap_err();
let err =
resolve_provider(Some("openai-compat"), None, Some(" "), None, None).unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}");
}
#[test]
fn resolve_provider_errors_when_provider_env_absent() {
// No implicit inference — absent BUZZ_AGENT_PROVIDER is an error.
let err = resolve_provider(None, None, None, None).unwrap_err();
let err = resolve_provider(None, None, None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}");
}
@@ -1361,19 +1434,19 @@ mod tests {
// When BUZZ_AGENT_PROVIDER=databricks, resolve_provider succeeds regardless
// of DATABRICKS_HOST/MODEL (those are validated later in from_env()).
assert_eq!(
resolve_provider(Some("databricks"), None, None, None).unwrap(),
resolve_provider(Some("databricks"), None, None, None, None).unwrap(),
Provider::Databricks
);
// Missing key for other providers still errors — no Databricks fallback.
let err = resolve_provider(Some("openai"), None, None, None).unwrap_err();
let err = resolve_provider(Some("openai"), None, None, None, None).unwrap_err();
assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}");
let err = resolve_provider(None, None, None, None).unwrap_err();
let err = resolve_provider(None, None, None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}");
}
#[test]
fn resolve_provider_unsupported_error_preserves_user_casing() {
let err = resolve_provider(Some("OpenAIish"), None, None, None).unwrap_err();
let err = resolve_provider(Some("OpenAIish"), None, None, None, None).unwrap_err();
assert!(err.contains("BUZZ_AGENT_PROVIDER=OpenAIish"));
}
@@ -2894,14 +2967,40 @@ mod tests {
#[test]
fn resolve_provider_openrouter_with_key() {
assert_eq!(
resolve_provider(Some("openrouter"), None, None, Some("sk-or-123")).unwrap(),
resolve_provider(Some("openrouter"), None, None, Some("sk-or-123"), None).unwrap(),
Provider::OpenRouter
);
}
#[test]
fn resolve_provider_openrouter_missing_key() {
let err = resolve_provider(Some("openrouter"), None, None, None).unwrap_err();
let err = resolve_provider(Some("openrouter"), None, None, None, None).unwrap_err();
assert!(err.contains("OPENROUTER_API_KEY"));
}
#[test]
fn resolve_provider_fireworks_with_key() {
assert_eq!(
resolve_provider(Some("fireworks"), None, None, None, Some("fw-123")).unwrap(),
Provider::Fireworks
);
}
#[test]
fn resolve_provider_fireworks_missing_key() {
let err = resolve_provider(Some("fireworks"), None, None, None, None).unwrap_err();
assert!(err.contains("FIREWORKS_API_KEY"));
}
/// Fireworks must not be reachable by pointing the OpenAI-compat route at
/// its host. That path looks like it would work — the wire format is
/// OpenAI-chat — but it replays tool-call fields Fireworks 400s on and
/// clamps `max` effort down to `xhigh`, so it fails in ways that read as
/// model behaviour rather than misconfiguration.
#[test]
fn openai_compat_key_does_not_satisfy_fireworks() {
let err =
resolve_provider(Some("fireworks"), None, Some("sk-openai"), None, None).unwrap_err();
assert!(err.contains("FIREWORKS_API_KEY"), "got {err}");
}
}
+202 -10
View File
@@ -158,10 +158,24 @@ impl Llm {
cfg.prompt_caching,
&cfg.openrouter_provider_order,
);
self.post_openrouter(cfg, &body)
self.post_chat_completions(cfg, &body)
.await
.and_then(parse_openai_with_reasoning_details)
}
Provider::Fireworks => {
// `effort` is passed through raw, NOT through
// `normalize_effort_for_openai_route`: that table is keyed on
// OpenAI model names and clamps `max` to `xhigh` for anything
// other than gpt-5.6, which would silently downgrade the level
// an operator asked for. Fireworks accepts the whole ladder
// except `minimal` (see `Provider::Fireworks`).
let mut body =
openai_body(cfg, system_prompt, history, tools, effective_model, effort);
apply_fireworks_mutations(&mut body, cfg.fireworks_service_tier.as_deref());
self.post_chat_completions(cfg, &body)
.await
.and_then(parse_openai)
}
Provider::OpenAi | Provider::Databricks => {
self.openai_request(
cfg,
@@ -294,14 +308,24 @@ impl Llm {
});
Ok(parse_anthropic(self.post_anthropic(cfg, &body).await?)?.text)
}
Provider::OpenRouter => {
let body = openrouter_summary_body(
Provider::OpenRouter | Provider::Fireworks => {
let mut body = chat_summary_body(
effective_model,
system_prompt,
user_prompt,
max_output_tokens,
);
let v = self.post_openrouter(cfg, &body).await?;
// The summary body is already Fireworks-legal (no tool
// calls, no reasoning), so it needs none of the stripping
// `apply_fireworks_mutations` does — only the tier, so a
// handoff summary bills and queues like the turn that
// triggered it instead of silently dropping to the default.
if cfg.provider == Provider::Fireworks {
if let Some(tier) = cfg.fireworks_service_tier.as_deref() {
body["service_tier"] = json!(tier);
}
}
let v = self.post_chat_completions(cfg, &body).await?;
Ok(parse_openai(v)?.text)
}
Provider::OpenAi | Provider::Databricks => {
@@ -721,7 +745,7 @@ impl Llm {
}
}
async fn post_openrouter(&self, cfg: &Config, body: &Value) -> Result<Value, AgentError> {
async fn post_chat_completions(&self, cfg: &Config, body: &Value) -> Result<Value, AgentError> {
let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/'));
let mut bearer = self.auth.bearer().await?;
let mut refreshed = false;
@@ -2204,7 +2228,7 @@ pub(crate) fn databricks_pkce_config(host: &str) -> PkceOAuthConfig {
/// flow; subsequent requests use the cache + refresh transparently.
pub(crate) fn build_token_source(cfg: &Config) -> Result<Arc<dyn TokenSource>, AgentError> {
match cfg.provider {
Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => {
Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter | Provider::Fireworks => {
Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone())))
}
Provider::Databricks | Provider::DatabricksV2 => {
@@ -2224,7 +2248,7 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result<Arc<dyn TokenSource>, A
/// `apply_openrouter_mutations`, which the summary path never calls).
/// It spells the token limit `max_tokens` directly for the same reason: the
/// mutation that renames it is never applied here.
fn openrouter_summary_body(
fn chat_summary_body(
effective_model: &str,
system_prompt: &str,
user_prompt: &str,
@@ -2538,6 +2562,62 @@ async fn openrouter_post(
))
}
/// Adapt an `openai_body` to Fireworks' Chat Completions contract.
///
/// Fireworks rejects unknown fields with HTTP 400 (`Extra inputs are not
/// permitted`) instead of ignoring them the way OpenAI does, so this is not
/// cosmetic tidying — every key below is one the shared body emits and
/// Fireworks refuses. Measured against the live API 2026-08-07.
fn apply_fireworks_mutations(body: &mut Value, service_tier: Option<&str>) {
let Some(obj) = body.as_object_mut() else {
return;
};
// Fireworks accepts both spellings today, but `max_tokens` is the
// documented one; `max_completion_tokens` reads as an undocumented alias,
// and an output cap that gets dropped on the floor is an expensive way to
// find out it was never load-bearing.
if let Some(max_tokens) = obj.remove("max_completion_tokens") {
obj.insert("max_tokens".into(), max_tokens);
}
// Strip the OpenRouter/Gemini replay fields off the history. `openai_body`
// writes back every unmodelled tool-call key that `parse_openai` captured
// into `ToolCall::provider_extra` — which exists so Gemini gets its
// `thoughtSignature` back — plus `reasoning_details` on assistant turns.
// Fireworks' own tool-call responses carry `index` and `name` beside
// `function`, so without this the *second* turn of any tool-using session
// 400s on a key Fireworks itself sent us. `reasoning_details` is only
// populated by `parse_openai_with_reasoning_details` (OpenRouter), so on
// this route it should never appear; it is stripped anyway because a
// history handed over from another provider would otherwise poison the
// first call.
if let Some(messages) = obj.get_mut("messages").and_then(Value::as_array_mut) {
for msg in messages.iter_mut() {
let Some(m) = msg.as_object_mut() else {
continue;
};
m.remove("reasoning_details");
if let Some(calls) = m.get_mut("tool_calls").and_then(Value::as_array_mut) {
for call in calls.iter_mut() {
if let Some(c) = call.as_object_mut() {
c.retain(|k, _| matches!(k.as_str(), "id" | "type" | "function"));
}
}
}
}
}
// Serving tier. Unlike OpenRouter — which accepts any string here and
// silently discards it — Fireworks validates against
// `auto|default|flex|priority`, so asking for `priority` actually means
// something. `Config::fireworks_service_tier` is already validated, and
// `None` omits the field so the account default applies.
if let Some(tier) = service_tier {
obj.insert("service_tier".into(), json!(tier));
}
}
fn apply_openrouter_mutations(
body: &mut Value,
effort: Option<ThinkingEffort>,
@@ -2706,6 +2786,7 @@ mod tests {
// Unpinned by default so the existing "no body shape adds a
// provider routing filter" assertions keep testing what they say.
openrouter_provider_order: Vec::new(),
fireworks_service_tier: None,
}
}
@@ -6077,6 +6158,117 @@ mod tests {
);
}
/// The regression this route exists for. `parse_openai` keeps every
/// unmodelled tool-call key in `ToolCall::provider_extra` so Gemini gets
/// its `thoughtSignature` back, and `openai_body` replays them beside
/// `function`. Fireworks' own tool-call responses carry `index` and `name`
/// there, and Fireworks 400s on unknown fields — so without the strip, the
/// *second* turn of every tool-using session dies on a key Fireworks itself
/// sent us, and it dies at exactly the point a benchmark trial has already
/// paid for its container.
#[test]
fn fireworks_body_strips_replayed_tool_call_extras() {
let c = cfg(Provider::Fireworks);
let mut extra = Map::new();
extra.insert("index".into(), json!(0));
extra.insert("name".into(), Value::Null);
let history = vec![HistoryItem::Assistant {
text: String::new(),
tool_calls: vec![ToolCall {
provider_id: "call_1".into(),
name: "shell".into(),
arguments: json!({ "cmd": "ls" }),
provider_extra: extra,
}],
reasoning_details: Some(json!([{ "type": "reasoning.text", "text": "t" }])),
}];
let mut body = openai_body(
&c,
"system",
&history,
&tools_vec(),
"accounts/fireworks/models/deepseek-v4-flash-0731",
None,
);
// Precondition: the shared body really does emit both, so this test
// fails loudly if `openai_body` ever stops and the strip goes stale.
let before = &body["messages"][1];
assert!(before.get("reasoning_details").is_some());
assert!(before["tool_calls"][0].get("index").is_some());
apply_fireworks_mutations(&mut body, None);
let call = &body["messages"][1]["tool_calls"][0];
assert!(
body["messages"][1].get("reasoning_details").is_none(),
"reasoning_details is an OpenRouter-ism Fireworks 400s on"
);
assert!(call.get("index").is_none() && call.get("name").is_none());
// Only the strip — the call itself must survive intact.
assert_eq!(call["id"], "call_1");
assert_eq!(call["type"], "function");
assert_eq!(call["function"]["name"], "shell");
}
#[test]
fn fireworks_body_renames_token_cap_and_sets_tier() {
let c = cfg(Provider::Fireworks);
let mut body = openai_body(
&c,
"system",
&[HistoryItem::User("hi".into())],
&[],
"accounts/fireworks/models/deepseek-v4-flash-0731",
Some(ThinkingEffort::Max),
);
apply_fireworks_mutations(&mut body, Some("priority"));
assert!(body.get("max_completion_tokens").is_none());
assert_eq!(body["max_tokens"], json!(c.max_output_tokens));
assert_eq!(body["service_tier"], "priority");
// `max` must reach the wire as `max`. The OpenAI route's per-model
// normalization clamps it to `xhigh` for anything but gpt-5.6, and
// silently running a "max effort" cell at xhigh would be a fabricated
// condition rather than a bug you can see.
assert_eq!(body["reasoning_effort"], "max");
}
/// The tier stays strictly opt-in: unset means the field is absent, not
/// `"default"`, so the account's own setting decides.
#[test]
fn fireworks_body_omits_tier_when_unset() {
let c = cfg(Provider::Fireworks);
let mut body = openai_body(
&c,
"system",
&[HistoryItem::User("hi".into())],
&[],
"accounts/fireworks/models/deepseek-v4-flash-0731",
None,
);
apply_fireworks_mutations(&mut body, None);
assert!(body.get("service_tier").is_none());
assert!(body.get("reasoning_effort").is_none());
}
/// Validated at parse time, not on the wire: Fireworks 400s a bad tier, and
/// a benchmark trial that dies on request one has already paid for its
/// container setup. The accepted set is Fireworks' own, read off its 400.
#[test]
fn fireworks_service_tier_parsing() {
use crate::config::parse_service_tier;
assert_eq!(parse_service_tier(None).unwrap(), None);
assert_eq!(parse_service_tier(Some(" ")).unwrap(), None);
assert_eq!(
parse_service_tier(Some(" Priority ")).unwrap(),
Some("priority".to_string())
);
for ok in ["auto", "default", "flex", "priority"] {
assert_eq!(parse_service_tier(Some(ok)).unwrap(), Some(ok.to_string()));
}
let err = parse_service_tier(Some("turbo")).unwrap_err();
assert!(err.contains("auto|default|flex|priority"), "got {err}");
}
#[test]
fn openrouter_body_empty_tools_no_effort() {
let c = cfg(Provider::OpenRouter);
@@ -6235,7 +6427,7 @@ mod tests {
#[test]
fn openrouter_summary_carries_neither_reasoning_nor_provider() {
let body = openrouter_summary_body(
let body = chat_summary_body(
"anthropic/claude-opus-4-7",
"summarize",
"text to summarize",
@@ -7831,7 +8023,7 @@ mod tests {
let mut c = cfg(Provider::OpenRouter);
c.base_url = url;
let err = llm.post_openrouter(&c, &json!({})).await.unwrap_err();
let err = llm.post_chat_completions(&c, &json!({})).await.unwrap_err();
assert!(
matches!(&err, AgentError::LlmAuth(s) if s.contains("static key rejected")),
"static 401 must surface as LlmAuth with 'static key rejected': got {err:?}"
@@ -7865,7 +8057,7 @@ mod tests {
let mut c = cfg(Provider::OpenRouter);
c.base_url = base;
let result = llm.post_openrouter(&c, &json!({})).await;
let result = llm.post_chat_completions(&c, &json!({})).await;
// `spawn_auth_stub` returns `{"ok":true}` on success.
assert!(
result.is_ok(),
+1
View File
@@ -1021,6 +1021,7 @@ dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"dirs",
"getrandom 0.4.3",
"hex",
"nix 0.31.3",