From bb2b8845cd3de0622b998ed69c6715892895d336 Mon Sep 17 00:00:00 2001 From: Scott Werner Date: Thu, 28 May 2026 15:30:26 -0400 Subject: [PATCH] docs(openrouter): document typed workflow attrs, cost telemetry; extend e2e - Rewrites the API-only Note about provider_options into a new "Provider routing in workflows" section that documents the five openrouter_* stage attrs with a .fabro example and an attr table. - Adds a "Cost telemetry" section noting that Fabro surfaces OpenRouter's authoritative usage.cost as Response.cost_usd with cost_source = "authoritative", distinguishing from catalog-derived "estimated" costs returned by direct providers. - Extends the live openrouter_complete integration test to switch from OpenAiCompatibleAdapter to OpenRouterAdapter and assert cost_usd.is_some() + cost_source == Authoritative. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/public/integrations/openrouter.mdx | 37 +++++++++++++++++++++-- lib/crates/fabro-llm/tests/integration.rs | 15 +++++---- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/docs/public/integrations/openrouter.mdx b/docs/public/integrations/openrouter.mdx index a3d049506..52dee0b1a 100644 --- a/docs/public/integrations/openrouter.mdx +++ b/docs/public/integrations/openrouter.mdx @@ -25,7 +25,7 @@ That's the entire configuration — Fabro ships the model catalog, base URL, and ## Data handling -Requests sent through OpenRouter pass through their infrastructure before reaching the underlying model. Prompt content, completions, and metadata are subject to OpenRouter's logging, moderation, and data-retention policies — review them at [openrouter.ai/docs/features/privacy-and-logging](https://openrouter.ai/docs/features/privacy-and-logging) before sending sensitive prompts. You can also use OpenRouter's per-request privacy controls (`provider.data_collection` and provider routing filters) via the `provider_options.openrouter` field described below. +Requests sent through OpenRouter pass through their infrastructure before reaching the underlying model. Prompt content, completions, and metadata are subject to OpenRouter's logging, moderation, and data-retention policies — review them at [openrouter.ai/docs/features/privacy-and-logging](https://openrouter.ai/docs/features/privacy-and-logging) before sending sensitive prompts. You can also use OpenRouter's per-request privacy controls (`provider.data_collection` and provider routing filters) via the `provider_options.openrouter` field described below, or via the `openrouter_data_collection` stage attribute in workflows. ## Attribution (optional, opt-in) @@ -109,6 +109,39 @@ digraph Example { } ``` +## Cost telemetry + +Every OpenRouter response includes an inline `usage.cost` field with authoritative USD billing. Fabro surfaces this as `Response.cost_usd` with `cost_source = "authoritative"`. For non-OpenRouter adapters, `cost_usd` is computed from catalog prices and `cost_source = "estimated"`. Hardcoded OpenRouter catalog prices are best-effort — OpenRouter's inline `usage.cost` is the source of truth, and authoritative cost takes precedence in any aggregation. + +## Provider routing in workflows + +Workflow stages can set OpenRouter routing options via typed `openrouter_*` stage attributes. These are translated into OpenRouter's nested `{provider: {...}, models: [...], transforms: [...]}` request shape under the hood. + +```dot title="workflow.fabro" +digraph Example { + graph [model_stylesheet="* { model: deepseek/deepseek-v4-flash; }"] + + start [shape=Mdiamond, label="Start"] + fast [ + label = "Fast tier" + prompt = "Answer concisely." + openrouter_provider_sort = "throughput" + openrouter_fallback_models = "qwen/qwen3.6-flash,minimax/minimax-m2.7" + ] + exit [shape=Msquare, label="Exit"] + + start -> fast -> exit +} +``` + +| Attribute | Type | Maps to | +| --- | --- | --- | +| `openrouter_provider_sort` | `"price"`, `"throughput"`, or `"latency"` | `provider.sort` | +| `openrouter_fallback_models` | comma-separated list of model IDs | `models` | +| `openrouter_transforms` | comma-separated list (e.g. `"middle-out"`) | `transforms` | +| `openrouter_allow_fallbacks` | `"true"` or `"false"` | `provider.allow_fallbacks` | +| `openrouter_data_collection` | `"allow"` or `"deny"` | `provider.data_collection` | + ## Provider routing (via API or SDK) OpenRouter accepts top-level request fields that aren't part of the OpenAI Chat Completions schema — `provider` (routing preferences), `models` (fallback list), `transforms`, `plugins`. Fabro forwards these through the `provider_options.openrouter` field on the request body when calling `POST /api/v1/completions` directly or constructing a `Request` via the Rust/TypeScript SDK: @@ -130,7 +163,7 @@ OpenRouter accepts top-level request fields that aren't part of the OpenAI Chat See [OpenRouter's provider routing documentation](https://openrouter.ai/docs/features/provider-routing) for the full set of supported fields. -Workflow files (`workflow.fabro`, `workflow.toml`) don't currently expose `provider_options` — stage attributes can set `model`, `provider`, and `reasoning_effort`, but not provider-specific request fields. Use the API or SDK directly when you need OpenRouter routing controls per call. +For routing controls in `.fabro` workflow files, use the typed `openrouter_*` stage attributes documented above instead of `provider_options.openrouter`. The JSON shape is reserved for direct API/SDK callers. ## Troubleshooting diff --git a/lib/crates/fabro-llm/tests/integration.rs b/lib/crates/fabro-llm/tests/integration.rs index 5f3db994f..aa79d7b5a 100644 --- a/lib/crates/fabro-llm/tests/integration.rs +++ b/lib/crates/fabro-llm/tests/integration.rs @@ -7,10 +7,8 @@ use std::sync::Arc; use fabro_llm::error::ProviderErrorKind; use fabro_llm::provider::ProviderAdapter; -use fabro_llm::providers::{ - AnthropicAdapter, GeminiAdapter, OpenAiAdapter, OpenAiCompatibleAdapter, -}; -use fabro_llm::types::{FinishReason, Message, Request}; +use fabro_llm::providers::{AnthropicAdapter, GeminiAdapter, OpenAiAdapter, OpenRouterAdapter}; +use fabro_llm::types::{CostSource, FinishReason, Message, Request}; use fabro_model::Catalog; use fabro_static::EnvVars; @@ -180,8 +178,8 @@ async fn gemini_complete() { async fn openrouter_complete() { let api_key = std::env::var(EnvVars::OPENROUTER_API_KEY).expect("OPENROUTER_API_KEY must be set"); - let adapter = OpenAiCompatibleAdapter::new(api_key, "https://openrouter.ai/api/v1") - .with_name("openrouter"); + let adapter = + OpenRouterAdapter::new(api_key, "https://openrouter.ai/api/v1").with_name("openrouter"); let request = make_request("deepseek/deepseek-v4-flash"); let response = adapter.complete(&request).await.unwrap(); @@ -192,6 +190,11 @@ async fn openrouter_complete() { assert!(response.usage.input_tokens > 0); assert!(response.usage.output_tokens > 0); assert_eq!(response.provider, "openrouter"); + assert!( + response.cost_usd.is_some(), + "OpenRouter responses should carry an authoritative usage.cost", + ); + assert_eq!(response.cost_source, Some(CostSource::Authoritative)); } async fn run_multi_turn_cache_test(