Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/architecture/framework/model-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ type PricingEntry struct {
// Costs - Other
SearchContextCostPerQuery *float64 `json:"search_context_cost_per_query,omitempty"`
CodeInterpreterCostPerSession *float64 `json:"code_interpreter_cost_per_session,omitempty"`
CostPerRequest *float64 `json:"cost_per_request,omitempty"`
}
```

Expand Down
9 changes: 5 additions & 4 deletions docs/mcp/auth/token-exchange.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ Entra's OBO flow surfaces several sharp edges that don't show up with Okta/Auth0
3. **Audience mismatch — `oidc: expected audience "api://..." got ["..."]`.** Once an app issues v2 tokens, its access tokens carry the app's **client ID (bare GUID)** in `aud`, not the `api://...` Application ID URI you registered — even though you requested the scope using the URI form. Set `token_exchange.audience` (and the MCP server's own audience-validation config) to the bare GUID, not `api://<guid>`.
4. **`AADSTS240002: Input id_token cannot be used as 'urn:ietf:params:oauth:grant-type:jwt-bearer' grant`.** The assertion Entra's OBO endpoint accepts must be an **access token**, never an `id_token`. Bifrost's SSO session already stores the right one for real caller traffic; this only bites when hand-crafting a token for manual testing — if you hit it, you grabbed the `id_token` instead of the `access_token`.
5. **`AADSTS501461: AcceptMappedClaims is only supported for a token audience matching the application GUID...`.** If your SSO app has custom claims mapping enabled (`acceptMappedClaims: true` in its manifest — typically on if you're mapping `roles`/`groups` claims for SCIM attribute mappings), requesting an access token audienced to that app's own `api://<guid>` Application ID URI is rejected outright. This only comes up when manually minting a test token against the SSO app's own scope (e.g. `<client-id>/access_as_user`, not `api://<client-id>/access_as_user` — Entra treats an app's own client ID as an implicit alternate resource identifier alongside its App ID URI, and that form isn't subject to the same restriction); Bifrost's own runtime exchange isn't affected.
6. **Adding a scope silently drops resource access.** Entra's `.default` scope (what `audience` builds by default) cannot be combined with other delegated scopes — Microsoft's own OBO docs are explicit about this (`AADSTS70011` otherwise). Bifrost handles the one documented exception automatically: `scopes: ["offline_access"]` combines with the default (`<audience>/.default offline_access`) rather than replacing it. Any other scope you configure fully replaces the default instead, per Entra's own rule — there is no way to request `.default` alongside a named custom scope in the same call. If you need narrower, specific permissions rather than everything `.default` grants, expose named scopes on the resource app (**Expose an API** → **Add a scope**) and list only those in `scopes`, without `.default` at all.

---

Expand All @@ -95,11 +96,11 @@ Each MCP client scopes its own exchange: which resource (`audience`) the token i
3. Pick **HTTP** or **SSE** as the connection type, fill in the **Connection URL**
4. Set **Auth Type** to **Token Exchange (On-Behalf-Of)** — only shown when an identity provider is configured
5. Fill in:
- **Audience** — the resource identifier this server is registered as at your identity provider (e.g. `api://jira-mcp`)
- **Audience** — the resource identifier this server is registered as at your identity provider (e.g. `api://jira-mcp` for Okta/Auth0/Keycloak). **For Microsoft Entra ID**, this is the resource app's **Application (client) ID** — a bare GUID, not the `api://...` Application ID URI shown under "Expose an API"; see [Audience mismatch](#microsoft-entra-id-known-setup-gotchas) below for why
- **Exchange application** — **Dedicated application** (default) or **Identity provider application**. Pick the latter, which reuses your SSO login application's credentials, if your provider requires it — Microsoft Entra ID always does; see [Prerequisites](#prerequisites)
- **Exchange Client ID** (required) / **Exchange Client Secret** (optional for public clients) — only shown when **Dedicated application** is selected
- **Authorization Server URL** (optional) — only needed if this audience is registered on a different Authorization Server than your SSO login uses; see [Okta: per-resource Authorization Server](#okta-per-resource-authorization-server)
- **Scopes** (optional, comma-separated) — include `offline_access` where your identity provider supports it
- **Scopes** (optional, comma-separated) — include `offline_access` where your identity provider supports it, so exchanges issue refresh tokens and the retained admin discovery credential stays self-renewing. For Microsoft Entra ID specifically: `offline_access` is the *only* additional scope that combines with the audience's default resource access — leave it as the sole entry to keep both; any other scope here replaces the default resource scope entirely rather than adding to it, and Entra rejects a `.default` + custom-scope combination outright — if you need narrower, specific permissions instead of the audience's default access, list only named resource scopes here (e.g. `api://jira-mcp/access_as_user`), with no `.default` entry at all
6. Click **Create** — Bifrost exchanges *your own* signed-in identity token, verifies the upstream connection, and discovers tools

</Tab>
Expand Down Expand Up @@ -235,12 +236,12 @@ Sending both an identity token and a virtual key is fine — [`dual_credential_c

| Field | Type | Required | Description |
|---|---|---|---|
| `audience` | string | Yes | Resource identifier this server is scoped to at the identity provider |
| `audience` | string | Yes | Resource identifier this server is scoped to at the identity provider. For Microsoft Entra ID, this is the resource app's Application (client) ID — a bare GUID, not its `api://...` Application ID URI. |
| `use_idp_credentials` | boolean | No (default `false`) | When `true`, performs the exchange as the SSO login application itself instead of `client_id`/`client_secret`, which are then ignored. Required for Microsoft Entra ID — see [Prerequisites](#prerequisites) |
| `client_id` | string | Yes, unless `use_idp_credentials` is `true` | The dedicated exchange application's client ID (env./vault. references supported) |
| `client_secret` | string | No | The exchange application's secret; omit for public clients. Ignored when `use_idp_credentials` is `true` (env./vault. references supported) |
| `authorization_server_url` | string | No | Overrides which Authorization Server the exchange is sent to. Only needed when `audience` is registered on a different Authorization Server than your SSO login uses — see [Okta: per-resource Authorization Server](#okta-per-resource-authorization-server). Leave unset for Entra and Auth0. |
| `scopes` | string[] | No | Scopes requested on the exchanged token; include `offline_access` for a self-renewing admin discovery credential. For Okta, must include a custom scope defined on the target Authorization Server — standard OIDC scopes are not valid here |
| `scopes` | string[] | No | Scopes requested on the exchanged token; include `offline_access` for a self-renewing admin discovery credential. For Okta, must include a custom scope defined on the target Authorization Server — standard OIDC scopes are not valid here. For Microsoft Entra ID, `offline_access` is the only scope Bifrost will combine with the audience's default resource access (`<audience>/.default offline_access`) — any other configured scope fully replaces the default instead of adding to it, matching Entra's own restriction on combining `.default` with other delegated scopes. |

`client_id` and `client_secret` are redacted in API responses, same as other MCP credential fields.

Expand Down
5 changes: 5 additions & 0 deletions docs/openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -96562,6 +96562,11 @@
"minimum": 0,
"description": "Anthropic data-residency multiplier applied when usage.inference_geo == \"us\"."
},
"cost_per_request": {
"type": "number",
"minimum": 0,
"description": "Flat fee added once per billed request, on top of any usage-based cost."
},
"ocr_cost_per_page": {
"type": "number",
"minimum": 0
Expand Down
9 changes: 7 additions & 2 deletions docs/openapi/schemas/management/governance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1672,8 +1672,9 @@ PricingOverrideRequestType:
PricingPatch:
type: object
description: >
Pricing fields to override. Only non-zero/non-null fields are applied.
All values are cost per unit in USD.
Pricing fields to override. All non-null fields are applied.
Usage-based values are costs per unit in USD. cost_per_request is a flat
fee in USD applied once per billed request.
properties:
# Costs - Text
input_cost_per_token:
Expand Down Expand Up @@ -1911,6 +1912,10 @@ PricingPatch:
type: number
minimum: 0
description: Anthropic data-residency multiplier applied when usage.inference_geo == "us".
cost_per_request:
type: number
minimum: 0
description: Flat fee added once per billed request, on top of any usage-based cost.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Costs - OCR
ocr_cost_per_page:
type: number
Expand Down
3 changes: 2 additions & 1 deletion docs/providers/custom-pricing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ Pricing overrides are defined under `governance.pricing_overrides`. Each entry r

## Pricing fields reference

Only fields with non-zero values are applied. Cost fields are per unit in USD; multiplier fields are dimensionless.
Any field you set (including `0`) is applied as an override; omitted fields are left unchanged. Cost fields are per unit in USD, except `cost_per_request`, which is a flat fee applied once per billed request; multiplier fields are dimensionless.

### Token costs

Expand Down Expand Up @@ -401,6 +401,7 @@ Only fields with non-zero values are applied. Cost fields are per unit in USD; m
| `search_context_cost_per_query` | Cost per web search context query |
| `code_interpreter_cost_per_session` | Cost per code interpreter session |
| `inference_geo_us_multiplier` | Data-residency cost multiplier applied when a request is served from a US inference region |
| `cost_per_request` | Flat fee added once per billed request, on top of any usage-based cost |
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### OCR costs

Expand Down
28 changes: 28 additions & 0 deletions framework/configstore/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ var configstoreMigrationSteps = []migrationStep{
{IDs: []string{"add_mcp_client_token_exchange_json_column"}, run: migrationAddMCPClientTokenExchangeJSONColumn},
{IDs: []string{"add_needs_session_stickiness_column"}, run: migrationAddNeedsSessionStickinessColumn},
{IDs: []string{"add_bedrock_endpoints_columns"}, run: migrationAddBedrockEndpointsColumns},
{IDs: []string{"add_cost_per_request_pricing_column"}, run: migrationAddCostPerRequestPricingColumn},
}

// quoteSQLiteIdentifier quotes a SQLite identifier, escaping any double quotes.
Expand Down Expand Up @@ -8619,6 +8620,33 @@ func migrationAddOCRPricingColumns(ctx context.Context, db *gorm.DB, logger sche
return nil
}

func migrationAddCostPerRequestPricingColumn(ctx context.Context, db *gorm.DB, logger schemas.Logger) error {
migrationName := "add_cost_per_request_pricing_column"
logger.Info("[configstore] starting migration %s", migrationName)
defer logger.Info("[configstore] finished migration %s", migrationName)
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: migrationName,
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
if err := addColumnIfNotExists(tx, logger, &tables.TableModelPricing{}, "cost_per_request"); err != nil {
return fmt.Errorf("failed to add column cost_per_request: %w", err)
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
if err := dropColumnIfExists(tx, logger, &tables.TableModelPricing{}, "cost_per_request"); err != nil {
return fmt.Errorf("failed to drop column cost_per_request: %w", err)
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running add_cost_per_request_pricing_column migration: %s", err.Error())
}
return nil
}

func migrationAddMCPExternalBaseURLColumn(ctx context.Context, db *gorm.DB, logger schemas.Logger) error {
migrationName := "add_mcp_external_base_url_column"
logger.Info("[configstore] starting migration %s", migrationName)
Expand Down
1 change: 1 addition & 0 deletions framework/configstore/rdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -2800,6 +2800,7 @@ var pricingSyncUpdateColumns = []string{
// Costs - Other
"search_context_cost_per_query",
"code_interpreter_cost_per_session",
"cost_per_request",
// Costs - OCR
"ocr_cost_per_page",
"annotation_cost_per_page",
Expand Down
2 changes: 2 additions & 0 deletions framework/configstore/tables/modelpricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ type TableModelPricing struct {
CodeInterpreterCostPerSession *float64 `gorm:"default:null;column:code_interpreter_cost_per_session" json:"code_interpreter_cost_per_session,omitempty"`
// Data-residency multiplier scaling all token/cache costs when Anthropic serves inference_geo:"us" (1.1x); nil = no multiplier.
InferenceGeoUSMultiplier *float64 `gorm:"default:null;column:inference_geo_us_multiplier" json:"inference_geo_us_multiplier,omitempty"`
// CostPerRequest is a flat fee added once per billed request, regardless of request type or usage.
CostPerRequest *float64 `gorm:"default:null;column:cost_per_request" json:"cost_per_request,omitempty"`

// Costs - OCR
OCRCostPerPage *float64 `gorm:"default:null;column:ocr_cost_per_page" json:"ocr_cost_per_page,omitempty"`
Expand Down
26 changes: 17 additions & 9 deletions framework/modelcatalog/datasheet/cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -318,28 +318,36 @@ func (s *Store) computeCostFromInput(input costInput, routingInfo schemas.Routin
}

// Route to the appropriate compute function
var cost float64
switch requestType {
case schemas.ChatCompletionRequest, schemas.TextCompletionRequest, schemas.ResponsesRequest, schemas.RealtimeRequest, schemas.CompactionRequest:
return computeTextCost(pricing, input.usage, input.tier)
cost = computeTextCost(pricing, input.usage, input.tier)
case schemas.EmbeddingRequest:
return computeEmbeddingCost(pricing, input.usage, input.tier)
cost = computeEmbeddingCost(pricing, input.usage, input.tier)
case schemas.RerankRequest:
return computeRerankCost(pricing, input.usage, input.tier)
cost = computeRerankCost(pricing, input.usage, input.tier)
case schemas.SpeechRequest:
return computeSpeechCost(pricing, input.usage, input.audioSeconds, input.audioTextInputChars, input.tier)
cost = computeSpeechCost(pricing, input.usage, input.audioSeconds, input.audioTextInputChars, input.tier)
case schemas.TranscriptionRequest:
return computeTranscriptionCost(pricing, input.usage, input.audioSeconds, input.audioTokenDetails, input.tier)
cost = computeTranscriptionCost(pricing, input.usage, input.audioSeconds, input.audioTokenDetails, input.tier)
case schemas.ImageGenerationRequest, schemas.ImageEditRequest, schemas.ImageVariationRequest:
return computeImageCost(pricing, input.imageUsage, input.imageSize, input.imageQuality, input.tier)
cost = computeImageCost(pricing, input.imageUsage, input.imageSize, input.imageQuality, input.tier)
case schemas.VideoGenerationRequest, schemas.VideoRemixRequest:
return computeVideoCost(pricing, input.usage, input.videoSeconds, input.tier)
cost = computeVideoCost(pricing, input.usage, input.videoSeconds, input.tier)
case schemas.OCRRequest:
return computeOCRCost(pricing, input.ocrProcessedPages, input.ocrIsAnnotated)
cost = computeOCRCost(pricing, input.ocrProcessedPages, input.ocrIsAnnotated)
case schemas.ContainerCreateRequest:
return computeContainerCreationCost(pricing)
cost = computeContainerCreationCost(pricing)
default:
return 0
}

// Flat per-request surcharge, billed once on top of usage-based cost
// whenever the resolved pricing row carries one.
if pricing.CostPerRequest != nil {
cost += *pricing.CostPerRequest
}
return cost
}

// ---------------------------------------------------------------------------
Expand Down
23 changes: 23 additions & 0 deletions framework/modelcatalog/datasheet/cost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1951,6 +1951,29 @@ func TestCalculateCost_ChatCompletion_GPT4o(t *testing.T) {
assert.InDelta(t, 0.08, cost, 1e-12)
}

// TestCalculateCost_ChatCompletion_CostPerRequest verifies the flat per-request
// fee is billed once, additive on top of the usual token-based cost.
func TestCalculateCost_ChatCompletion_CostPerRequest(t *testing.T) {
s := testStoreWithPricing(map[string]configstoreTables.TableModelPricing{
makeKey("gpt-4o", "openai", "chat"): {
Model: "gpt-4o", Provider: "openai", Mode: "chat",
InputCostPerToken: bifrost.Ptr(0.000005),
OutputCostPerToken: bifrost.Ptr(0.000015),
CostPerRequest: bifrost.Ptr(0.01),
},
})

resp := makeChatResponse(schemas.OpenAI, "gpt-4o", &schemas.BifrostLLMUsage{
PromptTokens: 10000,
CompletionTokens: 2000,
TotalTokens: 12000,
})

cost := s.CalculateCost(resp, nil)
// 10000*0.000005 + 2000*0.000015 + 0.01 (flat) = 0.08 + 0.01 = 0.09
assert.InDelta(t, 0.09, cost, 1e-12)
}

func TestCalculateCost_ChatCompletion_Claude35Sonnet_WithCache(t *testing.T) {
// Claude 3.5 Sonnet (Bedrock): $3/M input, $15/M output, cache_read=$0.3/M, cache_creation=$3.75/M
s := testStoreWithPricing(map[string]configstoreTables.TableModelPricing{
Expand Down
1 change: 1 addition & 0 deletions framework/modelcatalog/datasheet/overrides.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ func patchPricing(pricing configstoreTables.TableModelPricing, override Options)
{dst: &patched.CacheReadInputImageTokenCost, src: override.CacheReadInputImageTokenCost},
{dst: &patched.SearchContextCostPerQuery, src: override.SearchContextCostPerQuery},
{dst: &patched.CodeInterpreterCostPerSession, src: override.CodeInterpreterCostPerSession},
{dst: &patched.CostPerRequest, src: override.CostPerRequest},
{dst: &patched.OutputCostPerImageLowQuality, src: override.OutputCostPerImageLowQuality},
{dst: &patched.OutputCostPerImageMediumQuality, src: override.OutputCostPerImageMediumQuality},
{dst: &patched.OutputCostPerImageHighQuality, src: override.OutputCostPerImageHighQuality},
Expand Down
27 changes: 27 additions & 0 deletions framework/modelcatalog/datasheet/overrides_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,33 @@ func TestPatchPricing_PartialPatchOnlyChangesSpecifiedFields(t *testing.T) {
assert.Equal(t, 0.7, *patched.InputCostPerImage)
}

func TestPatchPricing_CostPerRequest(t *testing.T) {
base := configstoreTables.TableModelPricing{
Model: "gpt-4o",
Provider: "openai",
Mode: "chat",
}

patched := patchPricing(base, Options{
CostPerRequest: bifrost.Ptr(0.02),
})

require.NotNil(t, patched.CostPerRequest)
assert.Equal(t, 0.02, *patched.CostPerRequest)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func TestPatchPricing_CostPerRequestZero(t *testing.T) {
base := configstoreTables.TableModelPricing{
CostPerRequest: bifrost.Ptr(0.02),
}
patched := patchPricing(base, Options{
CostPerRequest: bifrost.Ptr(0.0),
})

require.NotNil(t, patched.CostPerRequest)
assert.Equal(t, 0.0, *patched.CostPerRequest)
}

func TestApplyScopedOverrides_ScopePrecedence(t *testing.T) {
s := newTestStore()

Expand Down
5 changes: 5 additions & 0 deletions framework/modelcatalog/datasheet/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ type Options struct {
SearchContextCostPerQuery *float64 `json:"search_context_cost_per_query,omitempty"`
CodeInterpreterCostPerSession *float64 `json:"code_interpreter_cost_per_session,omitempty"`
InferenceGeoUSMultiplier *float64 `json:"inference_geo_us_multiplier,omitempty"`
// CostPerRequest is a flat fee added once per billed request, on top of
// whatever usage-based cost the request otherwise computes to.
CostPerRequest *float64 `json:"cost_per_request,omitempty"`

// Costs - OCR
OCRCostPerPage *float64 `json:"ocr_cost_per_page,omitempty"`
Expand Down Expand Up @@ -676,6 +679,7 @@ func convertEntryToTablePricing(modelKey string, entry Entry) configstoreTables.
SearchContextCostPerQuery: entry.SearchContextCostPerQuery,
CodeInterpreterCostPerSession: entry.CodeInterpreterCostPerSession,
InferenceGeoUSMultiplier: entry.InferenceGeoUSMultiplier,
CostPerRequest: entry.CostPerRequest,

OCRCostPerPage: entry.OCRCostPerPage,
AnnotationCostPerPage: entry.AnnotationCostPerPage,
Expand Down Expand Up @@ -764,6 +768,7 @@ func convertTablePricingToEntry(pricing *configstoreTables.TableModelPricing) *E
SearchContextCostPerQuery: pricing.SearchContextCostPerQuery,
CodeInterpreterCostPerSession: pricing.CodeInterpreterCostPerSession,
InferenceGeoUSMultiplier: pricing.InferenceGeoUSMultiplier,
CostPerRequest: pricing.CostPerRequest,

OCRCostPerPage: pricing.OCRCostPerPage,
AnnotationCostPerPage: pricing.AnnotationCostPerPage,
Expand Down
Loading
Loading