diff --git a/benchmarks/harbor-buzz-orchestra/manifests/lb-tb-solo-deepseek-v4-flash-max-fw.yaml b/benchmarks/harbor-buzz-orchestra/manifests/lb-tb-solo-deepseek-v4-flash-max-fw.yaml new file mode 100644 index 000000000..3b29c2c51 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/manifests/lb-tb-solo-deepseek-v4-flash-max-fw.yaml @@ -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. diff --git a/benchmarks/harbor-buzz-orchestra/testbed/endpoints/fireworks-live.json b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/fireworks-live.json new file mode 100644 index 000000000..fba47c932 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/testbed/endpoints/fireworks-live.json @@ -0,0 +1,9 @@ +{ + "accounts/fireworks/models/deepseek-v4-flash-0731": { + "provider": "fireworks", + "api_key_env": "FIREWORKS_API_KEY", + "env": { + "FIREWORKS_SERVICE_TIER": "priority" + } + } +} diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index a31241084..e49919609 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -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, + /// 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, } 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 { 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, 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 { 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}"); + } } diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 34c1ebdcd..37567a84f 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -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 { + async fn post_chat_completions(&self, cfg: &Config, body: &Value) -> Result { 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, 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, 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, @@ -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(), diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index fbaa547a0..54eb3627e 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1021,6 +1021,7 @@ dependencies = [ "async-trait", "axum", "base64 0.22.1", + "dirs", "getrandom 0.4.3", "hex", "nix 0.31.3",