mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge pull request #13040 from Kilo-Org/fix/gateway-negative-prices
fix(gateway): ignore negative prices in model catalog
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+3
-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")
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user