mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
## Summary
First-class `Provider::OpenRouter` support joining the existing
anthropic/openai/databricks providers. Reuses the Chat Completions path
with targeted mutations for OpenRouter's routing contract.
**Core (`crates/buzz-agent`):**
- `Provider::OpenRouter` enum variant with `OPENROUTER_API_KEY`,
`BUZZ_AGENT_MODEL` → `OPENROUTER_MODEL` fallback, `OPENROUTER_BASE_URL`
env convention
- Body mutator: `reasoning: {effort}` when effort is configured, and
`max_completion_tokens` translated to OpenRouter's `max_tokens`
spelling; no `provider.require_parameters` filter (it routes only to
endpoints advertising every parameter in the body, which hard-404s a
valid model id); summaries get neither. `openai_body` is always called
with `effort=None` on the OpenRouter path — the `reasoning` object is
added by the mutator directly, so `reasoning_effort` is structurally
absent.
- Attribution headers: `HTTP-Referer: https://github.com/block/buzz`,
`X-OpenRouter-Title: Buzz`
- Error-inside-200 check in shared `parse_openai` (`finish_reason ==
"error"`)
- 401 auth handling: static API keys (`refresh_now` returns the same
token) fail terminal immediately with one wire request; PKCE/minting
sources get one retry with the fresh token.
- Status+`error_type` retry matrix (4-arm collapsed form): 429 (honor
`Retry-After`), 502 (retry), 503/`provider_overloaded` (honor
`Retry-After`), everything else including untyped 503 (bounded retries →
actionable routing message). 499 included matching shared `post()`
(#2175) for turn-timeout stall surfacing. Terminal failures wrapped in
`terminal_llm_error` for duration+attempt-count context.
- `anthropic/*` `cache_control` injection (model-gated, mixed-content
safe)
- Provider-agnostic `reasoning_details` opaque round-trip on
`HistoryItem::Assistant` for tool-call continuations — captured verbatim
in `parse_openai_with_reasoning_details`, replayed verbatim in
`openai_body`, byte-accounting charged. `provider_extra` passthrough
from `make_tool_call` composes independently.
**Desktop:**
- Readiness arms checking `OPENROUTER_API_KEY` + `OPENROUTER_MODEL`
- Model discovery via `{OPENROUTER_BASE_URL}/models` filtered on
`supported_parameters` contains `tools`
- Picker entry, credential config, effort table 3-file sync
**`desktop/src/features/agents/AGENTS.md`: no rules changed** — the
scoped rule requiring an explicit note is satisfied here.
Implements the gate-cleared plan from
`PLANS/OPENROUTER_PROVIDER_PLAN.md` (rev 3).
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
310 lines
11 KiB
TypeScript
310 lines
11 KiB
TypeScript
/**
|
|
* Source-of-truth constants for buzz-agent model-tuning configuration knobs.
|
|
*
|
|
* Values must stay in sync with `crates/buzz-agent/src/config.rs`
|
|
* `parse_thinking_effort` — that function is the authoritative list.
|
|
*/
|
|
|
|
/** Env var key for the thinking/effort level sent to the LLM. */
|
|
export const BUZZ_AGENT_THINKING_EFFORT = "BUZZ_AGENT_THINKING_EFFORT";
|
|
|
|
/** Env var key for the maximum output token count per turn. */
|
|
export const BUZZ_AGENT_MAX_OUTPUT_TOKENS = "BUZZ_AGENT_MAX_OUTPUT_TOKENS";
|
|
|
|
/** Env var key for the context window token limit. */
|
|
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";
|
|
|
|
/**
|
|
* Ordered set of valid thinking-effort values accepted by buzz-agent.
|
|
* Mirrors `parse_thinking_effort` in `crates/buzz-agent/src/config.rs`.
|
|
*/
|
|
export const BUZZ_AGENT_THINKING_EFFORT_VALUES = [
|
|
"none",
|
|
"minimal",
|
|
"low",
|
|
"medium",
|
|
"high",
|
|
"xhigh",
|
|
"max",
|
|
] as const;
|
|
|
|
export type ThinkingEffortValue =
|
|
(typeof BUZZ_AGENT_THINKING_EFFORT_VALUES)[number];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider-aware effort configuration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Describes which thinking-effort values are valid for a given provider+model,
|
|
* and which value is the provider's semantic default.
|
|
*
|
|
* `defaultValue = null` means the provider/model's default is to omit the
|
|
* thinking configuration entirely (i.e. "Inherit" is the natural default).
|
|
* This applies to Anthropic manual-budget models where the effort level maps
|
|
* to a budget_tokens count — there is no "default effort level" in the API.
|
|
*
|
|
* Mirrors the model-family tables in `crates/buzz-agent/src/config.rs`
|
|
* (`openai_efforts_for_model`, `is_manual_budget_model`,
|
|
* `is_adaptive_thinking_model`, `clamp_adaptive_effort`). Keep in sync.
|
|
*/
|
|
export type ProviderEffortConfig = {
|
|
validValues: ReadonlyArray<ThinkingEffortValue>;
|
|
/** Provider/model's semantic default, or `null` when Inherit is the default. */
|
|
defaultValue: ThinkingEffortValue | null;
|
|
};
|
|
|
|
const ALL_VALUES = BUZZ_AGENT_THINKING_EFFORT_VALUES;
|
|
|
|
/**
|
|
* Returns the valid thinking-effort values and semantic default for the
|
|
* given provider and optional model string.
|
|
*
|
|
* Model matching mirrors the Rust backend:
|
|
* - Anthropic: strip any endpoint-naming prefix, then test `is_manual_budget_model`
|
|
* / `is_adaptive_thinking_model` / `clamp_adaptive_effort` family checks.
|
|
* - OpenAI: strip any endpoint-naming prefix, then test `openai_efforts_for_model`
|
|
* family checks (boundary-aware: -pro before -5.x, digit/letter boundary).
|
|
* - DatabricksV2: strip prefix and route by model family.
|
|
* - Unknown/empty: all 7 values, default medium.
|
|
*
|
|
* Prefix stripping: finds the first occurrence of a known model-family token
|
|
* (`claude-`, `gpt-`) and drops everything before it. This handles any
|
|
* endpoint-naming convention (e.g. `databricks-`, `goose-`, `team-x-`) without
|
|
* maintaining an allowlist of known prefixes. If no family token is found, the
|
|
* raw model name is used as-is.
|
|
*/
|
|
export function getProviderEffortConfig(
|
|
providerId: string,
|
|
model?: string,
|
|
): ProviderEffortConfig {
|
|
const provider = providerId.toLowerCase();
|
|
// Strip arbitrary endpoint-naming prefix before model-family matching.
|
|
// Find the first occurrence of a known family token and drop everything before it.
|
|
// e.g. "goose-claude-fable-5" → "claude-fable-5"
|
|
// "team-x-gpt-5.5" → "gpt-5.5"
|
|
// "databricks-claude-3" → "claude-3"
|
|
// "claude-opus-4-7" → "claude-opus-4-7" (no prefix to strip)
|
|
const rawModel = (model ?? "").trim().toLowerCase();
|
|
const FAMILY_TOKENS = ["claude-", "gpt-"] as const;
|
|
const firstFamilyIdx = Math.min(
|
|
...FAMILY_TOKENS.map((tok) => {
|
|
const idx = rawModel.indexOf(tok);
|
|
return idx === -1 ? Infinity : idx;
|
|
}),
|
|
);
|
|
const m =
|
|
firstFamilyIdx === Infinity ? rawModel : rawModel.slice(firstFamilyIdx);
|
|
|
|
if (provider === "anthropic") {
|
|
return anthropicConfig(m);
|
|
}
|
|
if (provider === "openai") {
|
|
return openaiConfig(m);
|
|
}
|
|
if (provider === "databricks_v2") {
|
|
// Route by model family: claude* → Anthropic tables, gpt-5* → OpenAI tables.
|
|
// Non-Claude concrete models (e.g. llama-3) go through MlflowChatCompletions,
|
|
// which applies normalize_effort_for_openai_route → clamps max to xhigh.
|
|
// Route them through openaiConfig to exclude max. Only blank/unknown model
|
|
// uses the all-7 fallback (can't know the route without a concrete model).
|
|
if (m.startsWith("claude-")) {
|
|
return anthropicConfig(m);
|
|
}
|
|
if (gpt5FamilyModel(m)) {
|
|
return openaiConfig(m);
|
|
}
|
|
if (m.length > 0) {
|
|
// Concrete non-Claude, non-GPT model → MLflow path clamps max → xhigh.
|
|
return openaiConfig(m);
|
|
}
|
|
// Blank model — route unknown, show all 7.
|
|
return { validValues: ALL_VALUES, defaultValue: "medium" };
|
|
}
|
|
if (provider === "databricks") {
|
|
// databricks v1 uses OpenAI Chat Completions wire format.
|
|
return openaiConfig(m);
|
|
}
|
|
if (provider === "openrouter") {
|
|
return { validValues: ALL_VALUES, defaultValue: "medium" };
|
|
}
|
|
// openai-compat, unknown, empty — all values, default medium.
|
|
return { validValues: ALL_VALUES, defaultValue: "medium" };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Anthropic family tables
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function anthropicConfig(m: string): ProviderEffortConfig {
|
|
// Manual-budget models: claude-3* and claude-opus-4-5.
|
|
// These use budget_tokens — there is no "default effort level" in the API.
|
|
if (m.startsWith("claude-3") || m === "claude-opus-4-5") {
|
|
return {
|
|
validValues: ["low", "medium", "high"],
|
|
defaultValue: null,
|
|
};
|
|
}
|
|
// Adaptive models that support xhigh: opus-4-7+, sonnet-5.x, fable-5, mythos-5.
|
|
// mirrors clamp_adaptive_effort supports_xhigh check.
|
|
if (
|
|
m.startsWith("claude-opus-4-7") ||
|
|
m.startsWith("claude-opus-4-8") ||
|
|
m.startsWith("claude-sonnet-5") ||
|
|
m.startsWith("claude-fable-5") ||
|
|
m.startsWith("claude-mythos-5")
|
|
) {
|
|
return {
|
|
validValues: ["low", "medium", "high", "xhigh", "max"],
|
|
defaultValue: "high",
|
|
};
|
|
}
|
|
// Adaptive models that do NOT support xhigh: opus-4-6, sonnet-4-6, mythos-preview.
|
|
if (
|
|
m.startsWith("claude-opus-4-6") ||
|
|
m.startsWith("claude-sonnet-4-6") ||
|
|
m.startsWith("claude-mythos-preview")
|
|
) {
|
|
return {
|
|
validValues: ["low", "medium", "high", "max"],
|
|
defaultValue: "high",
|
|
};
|
|
}
|
|
// Unknown Anthropic model — assume adaptive with full support.
|
|
return {
|
|
validValues: ["low", "medium", "high", "xhigh", "max"],
|
|
defaultValue: "high",
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OpenAI family tables — mirrors openai_efforts_for_model in config.rs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Returns true if `m` contains a GPT-5 family token at a word boundary
|
|
* (not immediately followed by a digit or letter). Mirrors
|
|
* `gpt5_token_matches` / `gpt5_base_matches` in config.rs.
|
|
*/
|
|
function gpt5TokenMatches(m: string, token: string): boolean {
|
|
let start = 0;
|
|
while (true) {
|
|
const idx = m.indexOf(token, start);
|
|
if (idx === -1) return false;
|
|
const afterIdx = idx + token.length;
|
|
const afterChar = afterIdx < m.length ? m[afterIdx] : "";
|
|
// Boundary: end-of-string or a `-` separator (not a digit or letter).
|
|
if (afterChar === "" || afterChar === "-") return true;
|
|
start = afterIdx;
|
|
}
|
|
}
|
|
|
|
/** Like gpt5TokenMatches but also rejects short -<1-3 digit> suffixes (e.g. -5, -10). */
|
|
function gpt5BaseMatches(m: string, token: string): boolean {
|
|
let start = 0;
|
|
while (true) {
|
|
const idx = m.indexOf(token, start);
|
|
if (idx === -1) return false;
|
|
const afterIdx = idx + token.length;
|
|
const suffix = m.slice(afterIdx);
|
|
if (suffix === "") return true;
|
|
if (!suffix.startsWith("-")) {
|
|
start = afterIdx;
|
|
continue;
|
|
}
|
|
// Has a `-` suffix — check if it looks like a 1-3 digit version number.
|
|
const dashRest = suffix.slice(1);
|
|
if (/^\d{1,3}(?:[^a-z\d]|$)/i.test(dashRest)) {
|
|
start = afterIdx;
|
|
continue;
|
|
}
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/** Returns true if the model string belongs to any GPT-5 family. */
|
|
function gpt5FamilyModel(m: string): boolean {
|
|
return (
|
|
gpt5TokenMatches(m, "gpt-5-pro") ||
|
|
gpt5TokenMatches(m, "gpt5-pro") ||
|
|
gpt5TokenMatches(m, "gpt-5.6") ||
|
|
gpt5TokenMatches(m, "gpt5.6") ||
|
|
gpt5TokenMatches(m, "gpt-5-6") ||
|
|
gpt5TokenMatches(m, "gpt5-6") ||
|
|
gpt5TokenMatches(m, "gpt-5.5") ||
|
|
gpt5TokenMatches(m, "gpt5.5") ||
|
|
gpt5TokenMatches(m, "gpt-5.4") ||
|
|
gpt5TokenMatches(m, "gpt5.4") ||
|
|
gpt5TokenMatches(m, "gpt-5.1") ||
|
|
gpt5TokenMatches(m, "gpt5.1") ||
|
|
gpt5BaseMatches(m, "gpt-5") ||
|
|
gpt5BaseMatches(m, "gpt5")
|
|
);
|
|
}
|
|
|
|
function openaiConfig(m: string): ProviderEffortConfig {
|
|
// Check -pro before versioned suffixes (gpt-5-pro contains "gpt-5").
|
|
if (gpt5TokenMatches(m, "gpt-5-pro") || gpt5TokenMatches(m, "gpt5-pro")) {
|
|
return { validValues: ["high"], defaultValue: "high" };
|
|
}
|
|
if (
|
|
gpt5TokenMatches(m, "gpt-5.6") ||
|
|
gpt5TokenMatches(m, "gpt5.6") ||
|
|
gpt5TokenMatches(m, "gpt-5-6") ||
|
|
gpt5TokenMatches(m, "gpt5-6")
|
|
) {
|
|
return {
|
|
validValues: ["none", "low", "medium", "high", "xhigh", "max"],
|
|
defaultValue: "medium",
|
|
};
|
|
}
|
|
if (
|
|
gpt5TokenMatches(m, "gpt-5.5") ||
|
|
gpt5TokenMatches(m, "gpt5.5") ||
|
|
gpt5TokenMatches(m, "gpt-5-5") ||
|
|
gpt5TokenMatches(m, "gpt5-5") ||
|
|
gpt5TokenMatches(m, "gpt-5.4") ||
|
|
gpt5TokenMatches(m, "gpt5.4") ||
|
|
gpt5TokenMatches(m, "gpt-5-4") ||
|
|
gpt5TokenMatches(m, "gpt5-4")
|
|
) {
|
|
return {
|
|
validValues: ["none", "low", "medium", "high", "xhigh"],
|
|
defaultValue: "medium",
|
|
};
|
|
}
|
|
if (
|
|
gpt5TokenMatches(m, "gpt-5.1") ||
|
|
gpt5TokenMatches(m, "gpt5.1") ||
|
|
gpt5TokenMatches(m, "gpt-5-1") ||
|
|
gpt5TokenMatches(m, "gpt5-1")
|
|
) {
|
|
return {
|
|
validValues: ["none", "low", "medium", "high"],
|
|
defaultValue: "none",
|
|
};
|
|
}
|
|
if (gpt5BaseMatches(m, "gpt-5") || gpt5BaseMatches(m, "gpt5")) {
|
|
return {
|
|
validValues: ["minimal", "low", "medium", "high"],
|
|
defaultValue: "medium",
|
|
};
|
|
}
|
|
// Unknown OpenAI model — conservative fallback; max is enabled only for families whose table includes it.
|
|
return {
|
|
validValues: ["none", "minimal", "low", "medium", "high", "xhigh"],
|
|
defaultValue: "medium",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns true when the given runtime id is buzz-agent, which is the only
|
|
* runtime that supports the tier-1 model-tuning knobs above.
|
|
*/
|
|
export function isBuzzAgentRuntime(runtimeId: string): boolean {
|
|
return runtimeId === "buzz-agent";
|
|
}
|