mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Claude Code
parent
e25cbad425
commit
1cbf0bf3a2
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
Generated
+1
@@ -1021,6 +1021,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"getrandom 0.4.3",
|
||||
"hex",
|
||||
"nix 0.31.3",
|
||||
|
||||
Reference in New Issue
Block a user