Merge pull request #13040 from Kilo-Org/fix/gateway-negative-prices

fix(gateway): ignore negative prices in model catalog
This commit is contained in:
Christiaan Arnoldus
2026-08-10 14:45:05 +02:00
committed by GitHub
10 changed files with 112 additions and 25 deletions
@@ -0,0 +1,8 @@
---
"@kilocode/kilo-gateway": patch
"@kilocode/cli": patch
"kilo-code": patch
"@kilocode/kilo-jetbrains": patch
---
Ignore negative pricing entries from model catalogs and handle unpriced models gracefully in UI price formatting.
+1 -1
View File
@@ -76,7 +76,7 @@ type OpenRouterModel = z.infer<typeof openRouterModelSchema>
function parseApiPrice(price: string | null | undefined): number | undefined {
if (!price) return undefined
const parsed = parseFloat(price)
if (isNaN(parsed)) return undefined
if (isNaN(parsed) || parsed < 0) return undefined
return parsed * 1_000_000 // Convert $/token → $/M tokens
}
@@ -383,3 +383,66 @@ test("keeps organization catalog errors from silently falling back to personal m
expect(calls[0]).toContain("/api/gateway/transcription-models")
expect(headers[0]?.get("X-KILOCODE-ORGANIZATIONID")).toBe("org-1")
})
test("omits cost when pricing contains negative values (dynamic/auto-routed pricing)", async () => {
const orig = globalThis.fetch
stubFetch(
async () =>
new Response(
JSON.stringify({
data: [
{
id: "openrouter/auto",
name: "Auto Router",
context_length: 128000,
max_completion_tokens: 16384,
architecture: {
input_modalities: ["text"],
output_modalities: ["text"],
},
supported_parameters: ["tools"],
pricing: {
prompt: "-1",
completion: "-1",
},
},
{
id: "test/fixed-price",
name: "Fixed Price Model",
context_length: 128000,
max_completion_tokens: 16384,
architecture: {
input_modalities: ["text"],
output_modalities: ["text"],
},
supported_parameters: ["tools"],
pricing: {
prompt: "0.000003",
completion: "0.000015",
input_cache_read: "0.0000003",
input_cache_write: "-1",
},
},
],
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
)
const result = await fetchKiloModels({})
;(globalThis as any).fetch = orig
expect(result.error).toBeUndefined()
expect(result.models["openrouter/auto"]).toBeDefined()
expect(result.models["openrouter/auto"].cost).toBeUndefined()
expect(result.models["test/fixed-price"].cost).toEqual({
input: 3,
output: 15,
cache_read: 0.3,
})
})
@@ -157,14 +157,14 @@ internal class ModelDetailsPanel(
return buildList {
item.releaseDate?.let { add(KiloBundle.message("model.picker.details.released") to date(it)) }
if (!item.free) {
item.cost?.let { cost ->
item.cost?.takeIf { it.input > 0.0 || it.output > 0.0 }?.let { cost ->
add(KiloBundle.message("model.picker.details.input") to price(cost.input))
add(KiloBundle.message("model.picker.details.output") to price(cost.output))
add(KiloBundle.message("model.picker.details.cached") to cached(cost.input, cost.cache?.read))
add(KiloBundle.message("model.picker.details.average") to price(average(cost.input, cost.output, cost.cache?.read)))
} ?: run {
item.inputPrice?.let { add(KiloBundle.message("model.picker.details.input") to price(it)) }
item.outputPrice?.let { add(KiloBundle.message("model.picker.details.output") to price(it)) }
item.inputPrice?.takeIf { it > 0.0 }?.let { add(KiloBundle.message("model.picker.details.input") to price(it)) }
item.outputPrice?.takeIf { it > 0.0 }?.let { add(KiloBundle.message("model.picker.details.output") to price(it)) }
}
}
ctx?.let { add(KiloBundle.message("model.picker.details.context") to context(it)) }
@@ -18,6 +18,11 @@ describe("fmtPrice", () => {
expect(fmtPrice(0)).toBe("Free")
})
it("returns dash for negative price", () => {
expect(fmtPrice(-1)).toBe("—")
expect(fmtPrice(-1000000)).toBe("—")
})
it("uses 4 decimal places for sub-cent prices", () => {
expect(fmtPrice(0.005)).toBe("$0.0050/1M")
})
@@ -50,12 +50,13 @@ export const ModelPreview: Component<Props> = (props) => {
<Show when={m()}>
{(model) => {
const cost = () => model().cost
const hasPricing = () => cost() && (cost()!.input > 0 || cost()!.output > 0)
const bench = () => model().terminalBench
const cachedText = () => {
if (!cost()) return ""
if (!cost() || !hasPricing()) return ""
return fmtCachedPrice(cost()!) ?? language.t("model.preview.value.notSupported")
}
const avg = () => (cost() ? avgPrice(cost()!) : undefined)
const avg = () => (cost() && hasPricing() ? avgPrice(cost()!) : undefined)
const freeLabel = () => language.t("model.tag.free")
const dataLabel = () => freeDataLabel(language.t("model.tag.free"), language.t("model.tag.dataCollected"))
const autoLabel = () => autoSummary(model())
@@ -139,8 +140,8 @@ export const ModelPreview: Component<Props> = (props) => {
<span class="model-preview-value">{fmtDate(model().releaseDate!)}</span>
</Show>
{/* Pricing — hidden for free models */}
<Show when={cost() && !model().isFree}>
{/* Pricing — hidden for free models or models without fixed pricing */}
<Show when={cost() && !model().isFree && hasPricing()}>
<span class="model-preview-label">{language.t("model.preview.label.input")}</span>
<span class="model-preview-value">{fmtPrice(cost()!.input)}</span>
<span class="model-preview-label">{language.t("model.preview.label.output")}</span>
@@ -3,6 +3,7 @@
* Expects `n` in $/M tokens (as stored in model.cost.input / model.cost.output).
*/
export function fmtPrice(n: number): string {
if (n < 0) return "—"
if (n === 0) return "Free"
if (n < 0.01) return `$${n.toFixed(4)}/1M`
return `$${n.toFixed(2)}/1M`
@@ -8,6 +8,7 @@ interface Cost {
}
export function fmtPrice(n: number): string {
if (n < 0) return "—"
if (n === 0) return "Free"
if (n < 0.01) return `$${n.toFixed(4)}/1M`
return `$${n.toFixed(2)}/1M`
@@ -27,8 +27,9 @@ export function ModelInfoPanel(props: Props) {
const maxHeight = createMemo(() => Math.floor(dimensions().height / 2) - 3)
const cost = () => m().cost
const cached = () => (cost() ? fmtCachedPrice(cost()) : null)
const avg = () => (cost() ? avgPrice(cost()) : undefined)
const hasPricing = () => m().cost?.input > 0 || m().cost?.output > 0
const cached = () => (cost() && hasPricing() ? fmtCachedPrice(cost()) : null)
const avg = () => (cost() && hasPricing() ? avgPrice(cost()) : undefined)
const caps = () => m().capabilities
const inputs = () => caps()?.input
const outputs = () => caps()?.output
@@ -99,25 +100,27 @@ export function ModelInfoPanel(props: Props) {
</Show>
<Show when={!m().isFree}>
<box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Input</text>
<text fg={theme.text}>{m() ? fmtPrice(m().cost.input) : "—"}</text>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Output</text>
<text fg={theme.text}>{m() ? fmtPrice(m().cost.output) : "—"}</text>
</box>
<Show when={cached()}>
<Show when={hasPricing()}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Cached</text>
<text fg={theme.text}>{cached()}</text>
<text fg={theme.textMuted}>Input</text>
<text fg={theme.text}>{m() ? fmtPrice(m().cost.input) : "—"}</text>
</box>
</Show>
<Show when={avg() !== undefined}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Avg Cost</text>
<text fg={theme.text}>{m() ? fmtPrice(avg()!) : "—"}</text>
<text fg={theme.textMuted}>Output</text>
<text fg={theme.text}>{m() ? fmtPrice(m().cost.output) : "—"}</text>
</box>
<Show when={cached()}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Cached</text>
<text fg={theme.text}>{cached()}</text>
</box>
</Show>
<Show when={avg() !== undefined}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Avg Cost</text>
<text fg={theme.text}>{m() ? fmtPrice(avg()!) : "—"}</text>
</box>
</Show>
</Show>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Context</text>
@@ -6,6 +6,11 @@ describe("model info panel price formatting", () => {
expect(fmtPrice(0)).toBe("Free")
})
test("fmtPrice returns dash for negative values", () => {
expect(fmtPrice(-1)).toBe("—")
expect(fmtPrice(-1000000)).toBe("—")
})
test("fmtPrice uses four decimals for very small prices", () => {
expect(fmtPrice(0.0095)).toBe("$0.0095/1M")
})