Skip to content

Commit bf690ca

Browse files
authored
Merge pull request #16 from Floe-Labs/ci/cost-map-refresh
ci: scheduled LiteLLM cost-map refresh (decouples from monorepo)
2 parents 0e7b66d + 470848c commit bf690ca

4 files changed

Lines changed: 149 additions & 0 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Refresh cost map
2+
3+
on:
4+
schedule:
5+
- cron: '0 6 * * 1' # weekly, Monday 06:00 UTC
6+
workflow_dispatch:
7+
8+
concurrency:
9+
group: refresh-cost-map
10+
cancel-in-progress: false
11+
12+
permissions:
13+
contents: write
14+
pull-requests: write
15+
16+
jobs:
17+
refresh:
18+
runs-on: ubuntu-latest
19+
timeout-minutes: 10
20+
steps:
21+
# This job pushes a branch, so it KEEPS the checkout credentials (unlike the
22+
# read-only workflows that set persist-credentials: false). Pin ref: main so
23+
# a workflow_dispatch from another branch still refreshes from main and the
24+
# PR doesn't drag in that branch's unrelated commits.
25+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
26+
with:
27+
ref: main
28+
29+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
30+
with:
31+
node-version: '22'
32+
33+
- name: Refresh both cost maps from LiteLLM
34+
run: node scripts/update-cost-map.mjs
35+
36+
- name: Open a PR if the cost map changed
37+
env:
38+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39+
run: |
40+
set -euo pipefail
41+
if git diff --quiet -- src/floe_guard/cost_map.json js/src/cost_map.json; then
42+
echo "Cost map unchanged — nothing to do."
43+
exit 0
44+
fi
45+
BRANCH="chore/cost-map-refresh-${{ github.run_id }}"
46+
git config user.name "github-actions[bot]"
47+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
48+
git checkout -b "$BRANCH"
49+
git add src/floe_guard/cost_map.json js/src/cost_map.json
50+
git commit -m "chore: refresh LiteLLM cost map"
51+
git push origin "$BRANCH"
52+
gh pr create --base main --head "$BRANCH" \
53+
--title "chore: refresh LiteLLM cost map" \
54+
--body "Automated weekly refresh from LiteLLM's public price list, writing both vendored copies (Python + JS). **Review the price diff before merging.** Generated by \`scripts/update-cost-map.mjs\` via the *Refresh cost map* workflow."

js/src/cost_map.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@
3535
"litellm_provider": "anthropic",
3636
"mode": "chat"
3737
},
38+
"claude-fable-5": {
39+
"input_cost_per_token": 0.00001,
40+
"output_cost_per_token": 0.00005,
41+
"litellm_provider": "anthropic",
42+
"mode": "chat"
43+
},
3844
"claude-haiku-4-5": {
3945
"input_cost_per_token": 0.000001,
4046
"output_cost_per_token": 0.000005,

scripts/update-cost-map.mjs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* Refresh floe-guard's vendored LiteLLM cost map — BOTH copies — from the same
3+
* public source the Floe proxy uses. This keeps the open-source package's pricing
4+
* current without coupling it to the private monorepo.
5+
*
6+
* Writes (identically, so the cost-map-sync CI guard stays green):
7+
* - src/floe_guard/cost_map.json (Python package)
8+
* - js/src/cost_map.json (JS package)
9+
*
10+
* The transform mirrors the proxy's scripts/update-llm-cost-map.ts exactly, and
11+
* serialises with the same JSON.stringify(…, 2) so refreshes show up as clean
12+
* price diffs rather than reformatting noise. (A Python re-serialiser would emit
13+
* floats differently, e.g. 1e-06 vs 1e-7, and churn the whole file.)
14+
*
15+
* Run: node scripts/update-cost-map.mjs
16+
*/
17+
import { writeFileSync } from "node:fs";
18+
import { fileURLToPath } from "node:url";
19+
import { dirname, join } from "node:path";
20+
21+
const SOURCE_URL =
22+
process.env.LITELLM_COST_MAP_URL ??
23+
"https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
24+
25+
// Providers floe-guard prices (matches the proxy's ROUTABLE_PROVIDERS).
26+
const ROUTABLE_PROVIDERS = new Set(["openai", "anthropic"]);
27+
28+
/**
29+
* A model is vendored only if we can fully price it: a numeric input rate, a
30+
* routable provider, and either embedding mode (input-only) or chat mode WITH a
31+
* numeric output rate. Coercing a missing output rate to 0 would ship a chat
32+
* model that bills output free, which fail-closed pricing can't catch (0 is
33+
* finite). An excluded model is simply absent.
34+
*/
35+
function isUsable(v) {
36+
// Number.isFinite (not typeof === "number") so a NaN, or a huge upstream value
37+
// that JSON.parse turns into Infinity, is treated as unpriceable and dropped —
38+
// matching the fail-closed pricing paths.
39+
return (
40+
!!v &&
41+
Number.isFinite(v.input_cost_per_token) &&
42+
v.litellm_provider !== undefined &&
43+
ROUTABLE_PROVIDERS.has(v.litellm_provider) &&
44+
(v.mode === "embedding" ||
45+
(v.mode === "chat" && Number.isFinite(v.output_cost_per_token)))
46+
);
47+
}
48+
49+
const res = await fetch(SOURCE_URL);
50+
if (!res.ok) {
51+
throw new Error(`Failed to fetch LiteLLM cost map: HTTP ${res.status}`);
52+
}
53+
const raw = await res.json();
54+
55+
const entries = Object.entries(raw)
56+
.filter(([, v]) => isUsable(v))
57+
.sort(([a], [b]) => a.localeCompare(b));
58+
59+
// null-prototype: model keys come from remote JSON, so a "__proto__" (or similar)
60+
// key is stored as plain data instead of mutating the object's prototype.
61+
const out = Object.create(null);
62+
for (const [k, v] of entries) {
63+
out[k] = {
64+
input_cost_per_token: v.input_cost_per_token,
65+
output_cost_per_token: v.mode === "embedding" ? 0 : v.output_cost_per_token,
66+
litellm_provider: v.litellm_provider,
67+
mode: v.mode,
68+
};
69+
}
70+
71+
const json = `${JSON.stringify(out, null, 2)}\n`;
72+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
73+
const targets = [
74+
join(root, "src", "floe_guard", "cost_map.json"),
75+
join(root, "js", "src", "cost_map.json"),
76+
];
77+
for (const dest of targets) {
78+
writeFileSync(dest, json);
79+
}
80+
81+
console.log(
82+
`Wrote ${entries.length} models to ${targets.length} files (source: ${SOURCE_URL}).`,
83+
);

src/floe_guard/cost_map.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@
3535
"litellm_provider": "anthropic",
3636
"mode": "chat"
3737
},
38+
"claude-fable-5": {
39+
"input_cost_per_token": 0.00001,
40+
"output_cost_per_token": 0.00005,
41+
"litellm_provider": "anthropic",
42+
"mode": "chat"
43+
},
3844
"claude-haiku-4-5": {
3945
"input_cost_per_token": 0.000001,
4046
"output_cost_per_token": 0.000005,

0 commit comments

Comments
 (0)