add-pricing-field
GitHub在Bifrost定价引擎中端到端添加新的模型定价字段,涵盖结构体定义、数据库迁移、数据同步、计费逻辑、API文档及UI表单,确保无遗漏。
Trigger Scenarios
Install
npx skills add maximhq/bifrost --skill add-pricing-field -g -y
SKILL.md
Frontmatter
{
"name": "add-pricing-field",
"description": "Wire a new model-pricing field (a datasheet cost key like `cost_per_request`, `output_cost_per_video_per_second_720p`, etc.) end-to-end through Bifrost's pricing engine - Options struct, DB table + migration, datasheet sync upsert columns, cost calculation, custom pricing overrides, public API, OpenAPI docs, MDX docs, and the UI override form. Ends with a repo-wide probe to confirm nothing was missed. Invoked with \/add-pricing-field <field_name> or \/add-pricing-field (asks for the field).",
"allowed-tools": "Read, Grep, Glob, Bash, Edit, Write, AskUserQuestion"
}
Add Pricing Field
Add a new per-model pricing field to Bifrost's cost engine so it is parsed from the upstream datasheet, persisted, billed correctly, overridable, and documented — with no silent gap.
The pricing engine has one source-of-truth shape (Options in framework/modelcatalog/datasheet/types.go)
that gets mechanically mirrored into ~8 other places. Missing any one of them produces a field that
looks wired (compiles, shows up in one API) but silently doesn't bill, doesn't survive the 24h
datasheet resync, or can't be overridden — so treat every step below as mandatory, not optional.
Before You Start
Ask (or infer from context) three things about the new field:
- Field name — the exact upstream datasheet JSON key (e.g.
cost_per_request,output_cost_per_image_above_8_and_8_pixels). This becomes the Go field name (PascalCase) and the DB column name (as-is, snake_case). - Semantics — what usage quantity does it multiply, and is it additive on top of another
cost (like a flat per-request surcharge) or does it replace/tier an existing rate (like a
pixel-threshold override)? This determines where in
cost.goit plugs in — read the existingcompute*Costfunctions for the closest analog before writing new logic. - Which request type(s) it applies to — drives which
compute*Costfunction to touch and the UI'srequestTypeGroupstagging. At the Go level this includescontainer(schemas. ContainerCreateRequest), but the override UI'sREQUEST_TYPE_GROUPSonly has 7 groups (chat/ text/responses, embedding, rerank, audio, image, video, ocr) — there is no dedicatedcontainergroup. A container-priced field (e.g.code_interpreter_cost_per_session) still needs arequestTypeGroupsentry in Step 9, so tag it onto the existing group it's conceptually closest to (that field uses"chat") rather than inventing an unsupported"container"value.
If any of these is unclear from the user's message, ask before writing code — silently guessing the billing semantics of a money field is the one mistake in this skill that isn't easily caught by tests.
Key File Paths
| Concern | File | What to add |
|---|---|---|
| Canonical struct | framework/modelcatalog/datasheet/types.go |
Field on Options (with json:"..." tag) |
| Entry↔Table mapping | same file, convertEntryToTablePricing / convertTablePricingToEntry |
Mapping line in both directions |
| DB table | framework/configstore/tables/modelpricing.go |
Field on TableModelPricing (gorm column: tag) |
| Migration | framework/configstore/migrations.go |
New migrationAdd<X>Column func + registration in the migrations slice |
| Datasheet sync upsert | framework/configstore/rdb.go, pricingSyncUpdateColumns |
Column name string — easy to miss, silently breaks resync |
| Custom pricing overrides | framework/modelcatalog/datasheet/overrides.go, patchPricing |
{dst: &patched.X, src: override.X} entry |
| Cost calculation | framework/modelcatalog/datasheet/cost.go |
Wire into the relevant compute*Cost function (or computeCostFromInput if it's a flat cross-cutting surcharge) |
| Public API summary | framework/modelcatalog/modelinfo.go, ApplyModelInfo |
Only if the field belongs in schemas.Pricing (prompt/completion/request/image/web_search/internal_reasoning/cache read+write) — check core/schemas/models.go Pricing struct first |
| OpenAPI source | docs/openapi/schemas/management/governance.yaml, PricingPatch schema |
New property with type: number, minimum: 0, description |
| OpenAPI bundle | docs/openapi/openapi.json |
Regenerate — do not hand-edit (see Step 7) |
| Field reference docs | docs/providers/custom-pricing.mdx |
Row in the relevant section's table |
| Architecture excerpt | docs/architecture/framework/model-catalog.mdx |
Optional — file says "excerpt", but keep the "Costs - Other"-style section current when touching an adjacent field |
| UI override type | ui/lib/types/governance.ts, PricingOverridePatch |
field_name?: number; |
| UI override form | ui/app/workspace/custom-pricing/overrides/pricingOverrideSheet.tsx, PRICING_FIELDS |
{ key, label, group, requestTypeGroups } entry |
| Tests | framework/modelcatalog/datasheet/cost_test.go, overrides_test.go |
One cost-calculation test, one patchPricing test |
Workflow
Step 1: Confirm Semantics Against the Closest Existing Field
Before writing anything, grep for the field family this belongs to (image / video / audio / cache /
tiered-token / flat-fee) and read its existing compute*Cost handling in cost.go end to end. Prefer
the closest existing pattern. If no pattern matches, stop and ask for confirmation before introducing
a new billing path — do not force the field into an ill-fitting pattern. In particular check:
- Is it additive (billed once regardless of/on top of usage — like
search_context_cost_per_queryorcode_interpreter_cost_per_session)? → wire into the relevant compute function's return, or intocomputeCostFromInputdirectly if it applies across every request type. - Is it a threshold tier (like
output_cost_per_image_above_2048_and_2048_pixels)? → add acase pixels >= threshold && pricing.X != nil:branch in the existingswitchin the matchingcompute*Costfunction, ordered from largest threshold to smallest. - Is it a rate substitute for an existing base rate under some condition (service tier, region,
fast mode)? → follow the
tiered*Ratehelper pattern already used for priority/flex/fast tiers.
Step 2: Options Struct + Conversions
In framework/modelcatalog/datasheet/types.go:
- Add the field to
Options, in the section matching its family (// Costs - Image,// Costs - Other, etc.), with the exact upstream JSON tag. - Add the corresponding line to
convertEntryToTablePricing(Entry → TableModelPricing). - Add the corresponding line to
convertTablePricingToEntry(TableModelPricing → Entry).
Step 3: DB Table + Migration
framework/configstore/tables/modelpricing.go: add the field toTableModelPricingwithgorm:"default:null;column:<snake_case>"and matchingjsontag, in the matching comment section.framework/configstore/migrations.go:- Add a new
migrationAdd<Name>Columnfunction, modeled onmigrationAddOCRPricingColumns— useaddColumnIfNotExists/dropColumnIfExists, theconfigstorepackage-local aliases formigrator.AddColumnIfNotExists/migrator.DropColumnIfExists, never a bareALTER TABLE. - Register it as a new entry at the end of the migrations slice:
{IDs: []string{"add_<x>_column"}, run: migrationAdd<Name>Column}.
- Add a new
Step 4: Datasheet Sync Upsert Columns — Do Not Skip
In framework/configstore/rdb.go, add the column name to pricingSyncUpdateColumns, in the
comment section matching its family. This is the step most likely to be silently forgotten:
Create() on a brand-new row writes every column, so a fresh sync looks fine in testing — the bug
only shows up on the second sync of an existing model, when ON CONFLICT DO UPDATE silently
drops the field because it isn't in the explicit update-column list. If you skip this, the field
works until the next 24h resync, then quietly reverts to null forever.
Step 5: Custom Pricing Overrides
In framework/modelcatalog/datasheet/overrides.go, add {dst: &patched.X, src: override.X} to the
field list in patchPricing. No handler changes are needed — CreatePricingOverrideRequest/
UpdatePricingOverrideRequest embed Options generically via the Patch field.
Step 6: Cost Calculation
Wire the field into cost.go per the semantics decided in Step 1. If it's a flat, cross-cutting
surcharge (bills once per request regardless of type), add it in computeCostFromInput after
the per-request-type switch, additive on the switch's result — but leave the default: branch
(unrecognized request types) returning 0 unconditionally, so an unmapped type never gets billed
just because a pricing row happens to carry the new field.
computeCostFromInput is not on every path, though: calculateCostWithCache's direct-cache-hit
branch returns 0 before reaching it (no provider call happened at all), and its semantic-cache-hit
branch bills only computeCacheEmbeddingCost, bypassing computeCostFromInput entirely. A flat
surcharge wired only into computeCostFromInput therefore never fires on either cache-hit path.
Whether that's correct depends on what the field means — "per LLM call" (skip on cache hits, no
LLM call was made) vs. "per billed request regardless of cache" (should still fire). Don't assume
either answer silently: state the two cache-hit branches' behavior to the user and confirm which
one the new field should have before finalizing the wiring.
Step 7: Public API
Always check core/schemas/models.go's Pricing struct against the new field. It intentionally
exposes only a handful of fields (Prompt, Completion, Request, Image, WebSearch,
InternalReasoning, InputCacheRead, InputCacheWrite) — not every Options field belongs here.
If the new field maps onto one of these existing (possibly still-unpopulated) slots, populate it in
ApplyModelInfo (framework/modelcatalog/modelinfo.go) via formatCost. If it doesn't map to any
existing slot, do not add a new field to the public Pricing struct without the user explicitly
asking for a wider public surface — but the check itself is not optional.
Step 8: OpenAPI + Docs
docs/openapi/schemas/management/governance.yaml— add the property to thePricingPatchschema (type: number,minimum: 0, plusdescriptionif the field's meaning isn't self-evident from its name).- Regenerate the bundle — never hand-edit
openapi.json. Run the bundler in a subshell so the working directory doesn't leak into the following diff, then inspect content (not just--stat, which won't confirm which lines changed) from the repo root:
Confirm only the new field's lines appear in the diff.(cd docs/openapi && python3 bundle.py) git diff -- docs/openapi/openapi.json docs/providers/custom-pricing.mdx— add a row to the field-description table in the matching section (Text/Cache/Image/Audio-Video/Other/OCR).docs/architecture/framework/model-catalog.mdx— this Go excerpt is explicitly non-exhaustive about the fullOptionsstruct, but always add the new field to the section it belongs to so the excerpt doesn't drift stale relative to the fields it does list.
Per house convention, explain the exact doc lines you're about to add and get a quick confirmation
before writing to .mdx/.yaml files — unless the user's request already explicitly named docs as
in-scope for this change.
Step 9: UI
ui/lib/types/governance.ts— addfield_name?: number;toPricingOverridePatch, in the matching comment section.ui/app/workspace/custom-pricing/overrides/pricingOverrideSheet.tsx— add an entry toPRICING_FIELDS:key(exact JSON field name), a short humanlabel,group(which visual section it renders under — usually matches an existing sibling field's group), andrequestTypeGroups(which ofchat/embedding/rerank/audio/image/video/ocrit applies to, per Step 1's semantics — list every group it can price for a cross-cutting flat fee).
Step 10: Tests
framework/modelcatalog/datasheet/cost_test.go— one test exercising the new field throughStore.CalculateCostend-to-end (build a pricing row, a response, assert the dollar amount), modeled on the nearest existingTestCalculateCost_*test.framework/modelcatalog/datasheet/overrides_test.go— onepatchPricingtest asserting the new field passes through fromOptionstoTableModelPricing.framework/configstore/rdb_test.go— a regression test for the sync-upsert path itself, since neither test above would catch a forgottenpricingSyncUpdateColumnsentry (Step 4's top risk). Model it onTestUpsertModelPricesBatch_SQLite: upsert a row, re-upsert it with the new field set to a non-null value, then assert the re-fetched row still has it — this is exactly theON CONFLICT DO UPDATEpath that silently drops columns missing from the update-column list.
Step 11: Build + Test
set -euo pipefail
cd framework && go build ./... && go test ./modelcatalog/... ./configstore/...
cd ../transports && go build ./...
cd ../ui && ./node_modules/.bin/tsc --noEmit -p tsconfig.json
Run tsc bare, not piped through grep — a filter on the output launders tsc's own exit code
into grep's, so a real compiler failure with no matching text would falsely report success (and a
clean run with no matching text would falsely report failure). If you want to scan the output for
just the touched files, capture it to a variable first and check tsc's exit status separately:
tsc_out=$(./node_modules/.bin/tsc --noEmit -p tsconfig.json 2>&1); tsc_status=$?
echo "$tsc_out" | grep -i "pricingOverrideSheet\|governance.ts" || true
[ "$tsc_status" -eq 0 ]
Step 12: Repo-Wide Probe (Do Not Skip)
Before calling this done, grep the whole repo for a sibling field already known to be fully wired
(e.g. search_context_cost_per_query or code_interpreter_cost_per_session) and check every hit —
this catches fixture files, example configs, helm values, and any other place that enumerates
pricing fields you didn't think to check:
grep -rln "search_context_cost_per_query\|SearchContextCostPerQuery" \
--include="*.go" --include="*.ts" --include="*.tsx" --include="*.json" \
--include="*.yaml" --include="*.yml" --include="*.mdx" --include="*.md" . \
| grep -v node_modules | grep -v "/out/" | grep -v "\.next/"
For each hit not already covered by Steps 2–9, decide: is it an enumerated schema that needs the new field (fix it), or realistic fixture/example data that's intentionally non-exhaustive (leave it)? State which for anything ambiguous rather than silently skipping it.
Report a final table of every file touched, one row per file, so the user can review the full diff surface before it's committed.
Important Rules
- Never hand-edit
docs/openapi/openapi.json— always regenerate viadocs/openapi/bundle.pyafter editing the source YAML. - Never skip
pricingSyncUpdateColumnsinrdb.go— this is the single most common way a new pricing field silently stops working after the first datasheet resync. - Never add a bare
ALTER TABLEmigration — use theconfigstorepackage-local aliasesaddColumnIfNotExists/dropColumnIfExists(which wrapmigrator.AddColumnIfNotExists/migrator.DropColumnIfExists) so concurrent/rolling-deploy migrations stay idempotent. - Never widen
core/schemas/models.go's publicPricingstruct just to surface a new field — that struct is a deliberately small summary; ask the user first if they want it there. - Always confirm the
default:case incomputeCostFromInput's request-type switch keeps returning0— an unmapped request type must never get billed just because a resolved pricing row happens to carry the new field. - Always finish with the Step 12 repo-wide probe — it is what catches the locations this checklist's authors didn't think of.
Version History
- f270f90 Current 2026-08-15 03:00


