From dcb970aee1765a3cd6c86fcd3bed064c14a08a93 Mon Sep 17 00:00:00 2001
From: Navesz <58537948+Navesz@users.noreply.github.com>
Date: Tue, 25 Aug 2026 18:42:18 -0300
Subject: [PATCH 1/4] M3: the index reads like a product, and stops claiming
what it cannot prove
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The screen was a spreadsheet with CSS: 648 flat rows, the identifier in the
third column, six decimals in every cell, and the product's whole reason to
exist invisible. Four changes, three of which are not visual.
THE MODEL IS THE ENTITY, THE PROVIDER IS THE OFFER
`FLUX.1 [schnell]` at fal and `black-forest-labs/FLUX-1-schnell` at DeepInfra
are one row now. Without that, the question this project exists to answer —
where do I run THIS model cheapest — had nowhere to be asked.
`model-identity.ts` is an explicit rule table, never fuzzy matching: guessing
here tells somebody they can pay US$ 0.0005 for a model that costs US$ 0.009,
and they find out from their invoice. No match is a good answer.
THE GUARD, WHICH IS THE PART THAT MATTERS
Matching alone was not enough, and the screen proved it with numbers on it:
- `Qwen Image` (fal, US$ 20) and `Qwen-Image-Max` (DeepInfra, US$ 75) sat in
one row announcing "3.8x mais caro" about two different models.
- `FLUX 2 Lora` (fal, US$ 21) and `FLUX-2-max` (DeepInfra, US$ 100) — 4.8x,
also two different models, merged by a `/flux.*2/` catch-all.
- `Flux 2 Pro` and `FLUX 2 Pro Outpaint` shared a key.
So a variant now only matches when nothing in the DISPLAY NAME is left
unexplained. The endpoint id is read for matching and ignored for the guard,
because `fal-ai/flux-2/klein/4b/base/edit` is a route and `Qwen-Image-Edit` is
a model — reading them the same way cost the one honest comparison that existed.
Twelve cross-provider comparisons became three. Three is the true number, and
each one was checked name by name: klein 4B 1.6x, klein 9B 1.4x, pro 2.0x.
The "×N mais caro" figure was also wrong on its own terms: it divided the
group's cheapest offer by its dearest without checking whose they were, and fal
lists one model under several routes. It now compares each provider's best
price, which is what the column header always promised.
WHAT A PERSON SEES
- The unit is a THOUSAND images. `US$ 0,200` instead of `0.000200` — nobody
compares six decimals in their head, and nobody buys one image.
- Portuguese decimals. `US$ 0.200` reads as two hundred where the dot is a
thousands separator.
- A "dá para comparar" filter. The headline announced a number the person had
no way to isolate out of eighty-one rows.
- Provenance became a link. The provider's price page was already collected,
already travelled in the contract, and was thrown away — the one thing that
closes the trust loop was on the wire and in the bin.
- Cards below md, a real table above it, sticky header, row headers first for
a screen reader, named expanders, 40px targets, `--color-edge` at 3.38:1.
A NEW GATE: `controle`
The rule `/…klein.*4\s*b\b/i` reached the file with the `\b` collapsed into
U+0008, the literal BACKSPACE character. The regex stayed valid and silently
never matched again. Prettier formatted it, tsc compiled it, oxlint approved it,
and the test written from the same wrong assumption passed. The only symptom was
a smaller number on a screen.
`ferramental/controle` refuses any control character in source, and `portao`
proves it closes on exactly that byte.
---
apps/server/src/app/catalog.ts | 17 +
apps/server/src/domain/model-identity.ts | 291 ++++++++
apps/server/src/http/router.ts | 4 +
apps/server/tests/model-identity.test.ts | 137 ++++
apps/web/package.json | 2 +
apps/web/src/app.tsx | 23 +-
apps/web/src/credentials.tsx | 10 +-
apps/web/src/index-de-precos.tsx | 229 ------
apps/web/src/index.css | 106 ++-
apps/web/src/indice.tsx | 844 +++++++++++++++++++++++
apps/web/src/preco.ts | 109 +++
apps/web/tests/preco.test.ts | 106 +++
ferramental/controle/varrer-controle.mjs | 81 +++
ferramental/portao/provar-portao.mjs | 18 +
package-lock.json | 20 +
packages/contract/src/primitives.ts | 13 +
verificar.config.mjs | 6 +
17 files changed, 1764 insertions(+), 252 deletions(-)
create mode 100644 apps/server/src/domain/model-identity.ts
create mode 100644 apps/server/tests/model-identity.test.ts
delete mode 100644 apps/web/src/index-de-precos.tsx
create mode 100644 apps/web/src/indice.tsx
create mode 100644 apps/web/src/preco.ts
create mode 100644 apps/web/tests/preco.test.ts
create mode 100644 ferramental/controle/varrer-controle.mjs
diff --git a/apps/server/src/app/catalog.ts b/apps/server/src/app/catalog.ts
index 4d5f531..7189cc1 100644
--- a/apps/server/src/app/catalog.ts
+++ b/apps/server/src/app/catalog.ts
@@ -1,3 +1,4 @@
+import { identifyModel } from '../domain/model-identity.js'
import { costPerImage, isFresh, REFERENCE_TARGET, type Target } from '../domain/pricing.js'
import type { CatalogRow } from './ports.js'
import type { UnitOfWork } from './unit-of-work.js'
@@ -9,6 +10,10 @@ export interface CatalogDeps {
export interface IndexEntry {
readonly modelId: string
+ readonly modelKey: string
+ readonly modelLabel: string
+ readonly maker: string | null
+ readonly matchedBy: 'known-family' | 'unresolved'
readonly provider: string
readonly providerName: string
readonly name: string
@@ -61,9 +66,17 @@ export function createCatalog(deps: CatalogDeps) {
}
function toEntry(row: CatalogRow, target: Target, now: Date): IndexEntry {
+ // The endpoint id matters as much as the display name: fal titles a model
+ // "V4.0q [fast]", which identifies nothing on its own.
+ const identity = identifyModel(row.name, row.modelId)
+
if (!row.price) {
return {
modelId: row.modelId,
+ modelKey: identity.key,
+ modelLabel: identity.label,
+ maker: identity.maker,
+ matchedBy: identity.matchedBy,
provider: row.provider,
providerName: row.providerName,
name: row.name,
@@ -93,6 +106,10 @@ function toEntry(row: CatalogRow, target: Target, now: Date): IndexEntry {
return {
modelId: row.modelId,
+ modelKey: identity.key,
+ modelLabel: identity.label,
+ maker: identity.maker,
+ matchedBy: identity.matchedBy,
provider: row.provider,
providerName: row.providerName,
name: row.name,
diff --git a/apps/server/src/domain/model-identity.ts b/apps/server/src/domain/model-identity.ts
new file mode 100644
index 0000000..96f6f75
--- /dev/null
+++ b/apps/server/src/domain/model-identity.ts
@@ -0,0 +1,291 @@
+/**
+ * Recognising that two providers are selling the same model.
+ *
+ * This is the product's reason to exist, and until now the screen could not show
+ * it. fal calls it "FLUX.1 [schnell]"; DeepInfra calls it
+ * "black-forest-labs/FLUX-1-schnell". Same weights, same output, different price
+ * — and the table treated them as two unrelated rows, which is exactly the fact
+ * the whole index was built to surface.
+ *
+ * Pure: no clock, no I/O, no network. A name goes in, an identity comes out.
+ *
+ * ## Why this is a table of rules and not a clever algorithm
+ *
+ * There is no derivable id across providers. A path at WaveSpeed, a bare string
+ * at KIE, a versioned AIR at Runware, `owner/name` plus a hash at Replicate.
+ * Fuzzy matching on names is worse than useless here: matching FLUX.1 [dev] to
+ * FLUX.1 [schnell] would tell somebody they can pay US$ 0.0005 for a model that
+ * actually costs US$ 0.009, and they would find out from their invoice.
+ *
+ * So: an explicit table, a conservative parser, and NO MATCH is a perfectly good
+ * answer. A model that does not resolve keeps its own row, which is the current
+ * behaviour and is never wrong.
+ */
+
+export interface ModelIdentity {
+ /** Stable key for grouping. `flux.1-schnell`, `nano-banana-pro`. */
+ readonly key: string
+ /** What a person calls it. `FLUX.1 [schnell]`. */
+ readonly label: string
+ /** Who made the weights, when it is known. */
+ readonly maker: string | null
+ /** How the match was made, so the screen can be honest about confidence. */
+ readonly matchedBy: 'known-family' | 'unresolved'
+}
+
+interface FamilyRule {
+ readonly key: string
+ readonly label: string
+ readonly maker: string
+ /**
+ * Every variant is listed, and the ORDER MATTERS: the most specific pattern
+ * first, because `flux.2 klein` must not be swallowed by `flux.2`.
+ */
+ readonly variants: ReadonlyArray<{
+ readonly key: string
+ readonly label: string
+ readonly match: RegExp
+ }>
+}
+
+/**
+ * The families worth grouping, chosen from what the collectors actually return.
+ *
+ * THREE model variants currently carry a price from more than one provider:
+ * FLUX.2 [klein] 4B, FLUX.2 [klein] 9B and FLUX.2 [pro]. Three is the honest
+ * number and it used to read as twelve, which is the more interesting fact — the
+ * other nine were rules matching names they did not explain, and each one put a
+ * "×N mais caro" on the screen comparing two different models.
+ *
+ * It is thin for a reason worth stating: ten of the thirteen providers publish no
+ * machine-readable price at all, so there is little to cross-reference yet. The
+ * grouping gets valuable as those arrive by hand, and it gets valuable HONESTLY
+ * only because the guard below refuses everything it cannot explain.
+ */
+const FAMILIES: readonly FamilyRule[] = [
+ {
+ key: 'flux',
+ label: 'FLUX',
+ maker: 'Black Forest Labs',
+ variants: [
+ // 4B e 9B são pesos diferentes com preços diferentes, e o grupo os juntava:
+ // o fal vende o 4B a US$ 9 e o 9B a US$ 11 sob a mesma chave.
+ {
+ key: 'flux.2-klein-9b',
+ label: 'FLUX.2 [klein] 9B',
+ match: /flux[.\s-]*2.*klein.*9\s*b\b/i,
+ },
+ {
+ key: 'flux.2-klein-4b',
+ label: 'FLUX.2 [klein] 4B',
+ match: /flux[.\s-]*2.*klein.*4\s*b\b/i,
+ },
+ { key: 'flux.2-klein', label: 'FLUX.2 [klein]', match: /flux[.\s-]*2.*klein/i },
+ { key: 'flux.2-flex', label: 'FLUX.2 [flex]', match: /flux[.\s-]*2.*flex/i },
+ { key: 'flux.2-pro', label: 'FLUX.2 [pro]', match: /flux[.\s-]*2.*pro/i },
+ { key: 'flux.2-dev', label: 'FLUX.2 [dev]', match: /flux[.\s-]*2.*dev/i },
+ { key: 'flux.2-max', label: 'FLUX.2 [max]', match: /flux[.\s-]*2.*max/i },
+ { key: 'flux.2-flash', label: 'FLUX.2 [flash]', match: /flux[.\s-]*2.*flash/i },
+ { key: 'flux.2-turbo', label: 'FLUX.2 [turbo]', match: /flux[.\s-]*2.*turbo/i },
+ { key: 'flux.2-edit', label: 'FLUX.2 Edit', match: /flux[.\s-]*2.*edit/i },
+ { key: 'flux.2', label: 'FLUX.2', match: /flux[.\s-]*2(?![\s.-]*\w)/i },
+ { key: 'flux.1-kontext', label: 'FLUX.1 Kontext', match: /flux[.\s-]*1?.*kontext/i },
+ { key: 'flux.1-schnell', label: 'FLUX.1 [schnell]', match: /flux[.\s-]*1?.*schnell/i },
+ { key: 'flux.1-dev', label: 'FLUX.1 [dev]', match: /flux[.\s-]*1?.*dev/i },
+ { key: 'flux.1-pro', label: 'FLUX.1 [pro]', match: /flux[.\s-]*1?[\s.-]*pro\b/i },
+ { key: 'flux.1', label: 'FLUX.1', match: /flux[.\s-]*1(?![\s.-]*\w)/i },
+ ],
+ },
+ {
+ key: 'nano-banana',
+ label: 'Nano Banana',
+ maker: 'Google',
+ variants: [
+ { key: 'nano-banana-pro', label: 'Nano Banana Pro', match: /nano[\s-]*banana.*pro/i },
+ { key: 'nano-banana-2', label: 'Nano Banana 2', match: /nano[\s-]*banana.*2/i },
+ { key: 'nano-banana', label: 'Nano Banana', match: /nano[\s-]*banana/i },
+ ],
+ },
+ {
+ key: 'gemini-image',
+ label: 'Gemini Image',
+ maker: 'Google',
+ variants: [
+ {
+ key: 'gemini-3-pro-image',
+ label: 'Gemini 3 Pro Image',
+ match: /gemini[\s-]*3.*pro.*image/i,
+ },
+ {
+ key: 'gemini-3-flash-image',
+ label: 'Gemini 3 Flash Image',
+ match: /gemini[\s-]*3.*flash.*image/i,
+ },
+ { key: 'gemini-image', label: 'Gemini Image', match: /gemini.*image/i },
+ ],
+ },
+ {
+ key: 'qwen-image',
+ label: 'Qwen-Image',
+ maker: 'Alibaba',
+ variants: [
+ { key: 'qwen-image-edit', label: 'Qwen-Image Edit', match: /qwen[\s-]*image.*edit/i },
+ { key: 'qwen-image', label: 'Qwen-Image', match: /qwen[\s-]*image/i },
+ ],
+ },
+ {
+ key: 'seedream',
+ label: 'Seedream',
+ maker: 'ByteDance',
+ variants: [
+ { key: 'seedream-4', label: 'Seedream 4', match: /seedream[\s-]*4/i },
+ { key: 'seedream-3', label: 'Seedream 3', match: /seedream[\s-]*3/i },
+ { key: 'seedream', label: 'Seedream', match: /seedream/i },
+ ],
+ },
+ {
+ key: 'ideogram',
+ label: 'Ideogram',
+ maker: 'Ideogram',
+ variants: [
+ { key: 'ideogram-v4', label: 'Ideogram V4', match: /ideogram.*v?4/i },
+ { key: 'ideogram-v3', label: 'Ideogram V3', match: /ideogram.*v?3/i },
+ { key: 'ideogram', label: 'Ideogram', match: /ideogram/i },
+ ],
+ },
+ {
+ key: 'sdxl',
+ label: 'SDXL',
+ maker: 'Stability AI',
+ variants: [
+ {
+ key: 'sdxl-turbo',
+ label: 'SDXL Turbo',
+ match: /sdxl.*turbo|stable[\s-]*diffusion.*xl.*turbo/i,
+ },
+ { key: 'sdxl', label: 'SDXL', match: /sdxl|stable[\s-]*diffusion[\s-]*xl/i },
+ ],
+ },
+ {
+ key: 'gpt-image',
+ label: 'GPT Image',
+ maker: 'OpenAI',
+ variants: [
+ { key: 'gpt-image-2', label: 'GPT Image 2', match: /gpt[\s-]*image[\s-]*2/i },
+ { key: 'gpt-image-1', label: 'GPT Image 1', match: /gpt[\s-]*image[\s-]*1?/i },
+ ],
+ },
+ {
+ key: 'recraft',
+ label: 'Recraft',
+ maker: 'Recraft',
+ variants: [
+ { key: 'recraft-v3', label: 'Recraft V3', match: /recraft.*v?3/i },
+ { key: 'recraft', label: 'Recraft', match: /recraft/i },
+ ],
+ },
+ {
+ key: 'imagen',
+ label: 'Imagen',
+ maker: 'Google',
+ variants: [
+ { key: 'imagen-4', label: 'Imagen 4', match: /imagen[\s-]*4/i },
+ { key: 'imagen', label: 'Imagen', match: /imagen/i },
+ ],
+ },
+]
+
+/**
+ * Words that describe a TASK or a knob, not a model.
+ *
+ * Stripped before matching so "FLUX.2 [klein] 4B LoRA" and
+ * "black-forest-labs/FLUX-2-dev" reach the same rule. Left in, they would split
+ * one model into a handful of near-duplicate rows and hide the very comparison
+ * this file exists to produce.
+ *
+ * `edit`, `lora`, `controlnet`, `redux` and `fill` are deliberately NOT in this
+ * list, and a test enforces it. They look like task words and are not: each one
+ * names a SEPARATE ENDPOINT with a SEPARATE PRICE. The screen caught it live —
+ * with `lora` stripped, fal's "FLUX 2 Lora" at US$ 21 and DeepInfra's
+ * "FLUX-2-max" at US$ 100 landed in one row and the table announced "o mais caro
+ * custa 4,8×" about two models that are not the same thing. Where a family has a
+ * real variant, it gets its own rule above the general one.
+ */
+const NOISE =
+ /\b(text[\s-]?to[\s-]?image|image[\s-]?to[\s-]?image|t2i|i2i|txt2img|img2img|inpainting|outpainting|upscal\w*|api|endpoint|model|preview|latest|beta)\b/gi
+
+/**
+ * Palavras que, SOBRANDO depois do casamento, dizem "isto é outro modelo".
+ *
+ * A guarda que faltava, e a razão de ela existir é uma lista de mentiras que a
+ * tela chegou a exibir com número e tudo:
+ *
+ * - `Qwen Image` (fal, US$ 20) e `Qwen-Image-Max` (DeepInfra, US$ 75) viraram uma
+ * linha só anunciando "3,8× mais caro". Max é outro modelo.
+ * - `Flux 2 Pro` e `FLUX 2 Pro Outpaint` caíram na mesma chave — um roda a partir
+ * de um prompt, o outro exige uma imagem e uma máscara.
+ * - `Ideogram V4` e `Ideogram V4 (LoRA)` são endpoints com preços próprios.
+ *
+ * Cada um desses era uma regra específica a mais na tabela, para sempre, uma por
+ * palavra que um provedor inventasse. Isto é uma regra só, e ela erra para o lado
+ * certo: sobrou algo que a regra não explica, o modelo fica sem identidade e
+ * ganha a própria linha — que é o comportamento de hoje e nunca está errado.
+ *
+ * `nano` fica de fora de propósito: é metade do nome de Nano Banana.
+ *
+ * A guarda olha o NOME EXIBIDO, nunca o id do endpoint, e a diferença tem custo.
+ * `fal-ai/flux-2/klein/4b/base/edit` é a rota imagem→imagem do klein 4B Base: o
+ * `/edit` ali é caminho, não modelo. `Qwen/Qwen-Image-Edit` traz `Edit` no nome, e
+ * ali é modelo. Olhando os dois juntos, "FLUX.2 [klein] 4B Base" perdia a
+ * identidade por causa de um segmento de rota e a única comparação honesta que
+ * existia entre fal e DeepInfra sumia da tela.
+ */
+const QUALIFIERS =
+ /\b(max|plus|lite|mini|ultra|outpaint\w*|inpaint\w*|lora|controlnet|redux|fill|gallery|distill\w*|realism|hdr|reference|multi|edit)\b/i
+
+export function identifyModel(rawName: string, endpointId?: string): ModelIdentity {
+ // Both the display name and the endpoint id get a look. fal's title is
+ // "V4.0q [fast]" for a model whose id is "fal-ai/ideogram/v4"; the name alone
+ // is not enough to tell what it is.
+ const limpar = (t: string): string =>
+ t
+ .replace(/^[a-z0-9.-]+:/i, '')
+ .replace(NOISE, ' ')
+ .replace(/[_/]/g, ' ')
+
+ const haystack = limpar(`${rawName} ${endpointId ?? ''}`)
+ const nome = limpar(rawName)
+
+ for (const family of FAMILIES) {
+ for (const variant of family.variants) {
+ if (!variant.match.test(haystack)) continue
+
+ // O que a regra NÃO explicou do nome exibido. Se sobrar um qualificador
+ // ali, a regra casou com o nome errado e um preço de outro modelo entraria
+ // nesta linha.
+ if (QUALIFIERS.test(nome.replace(variant.match, ' '))) continue
+
+ return {
+ key: variant.key,
+ label: variant.label,
+ maker: family.maker,
+ matchedBy: 'known-family',
+ }
+ }
+ }
+
+ // No match is a good answer. The model keeps its own identity and its own row,
+ // which is exactly what the table does today and is never wrong. Guessing here
+ // would tell somebody they can pay US$ 0.0005 for a model that costs US$ 0.009.
+ return {
+ key: `unresolved:${rawName.toLowerCase().trim()}`,
+ label: rawName,
+ maker: null,
+ matchedBy: 'unresolved',
+ }
+}
+
+/** Every family the table knows how to group, for a filter that offers real options. */
+export function knownFamilies(): ReadonlyArray<{ key: string; label: string; maker: string }> {
+ return FAMILIES.map((f) => ({ key: f.key, label: f.label, maker: f.maker }))
+}
diff --git a/apps/server/src/http/router.ts b/apps/server/src/http/router.ts
index ae9fdfb..1d20597 100644
--- a/apps/server/src/http/router.ts
+++ b/apps/server/src/http/router.ts
@@ -106,6 +106,10 @@ const catalogIndex = os.catalog.index.handler(async ({ input, context }) => {
target: { width: input.width, height: input.height, steps: input.steps },
entries: entries.map((entry) => ({
modelId: entry.modelId,
+ modelKey: entry.modelKey,
+ modelLabel: entry.modelLabel,
+ maker: entry.maker,
+ matchedBy: entry.matchedBy,
provider: entry.provider,
providerName: entry.providerName,
name: entry.name,
diff --git a/apps/server/tests/model-identity.test.ts b/apps/server/tests/model-identity.test.ts
new file mode 100644
index 0000000..44b414c
--- /dev/null
+++ b/apps/server/tests/model-identity.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, it } from 'vitest'
+import { identifyModel, knownFamilies } from '../src/domain/model-identity.js'
+
+/**
+ * The tests that decide whether the price comparison is trustworthy.
+ *
+ * A false match here is not a cosmetic bug: it tells somebody they can pay
+ * US$ 0.0005 for a model that actually costs US$ 0.009, and they find out from
+ * their invoice. So the bar is asymmetric — missing a match is fine, inventing
+ * one is not.
+ */
+
+describe('recognising the same model across providers', () => {
+ it('matches the pair the whole product exists to show', () => {
+ // fal's title and DeepInfra's model_name for the same weights.
+ const fal = identifyModel('FLUX.1 [schnell]', 'fal-ai/flux/schnell')
+ const deepinfra = identifyModel('black-forest-labs/FLUX-1-schnell')
+
+ expect(fal.key).toBe('flux.1-schnell')
+ expect(deepinfra.key).toBe(fal.key)
+ expect(fal.maker).toBe('Black Forest Labs')
+ })
+
+ it('matches FLUX.2, which is where the real cross-provider data is today', () => {
+ expect(identifyModel('FLUX.2 [dev]').key).toBe('flux.2-dev')
+ expect(identifyModel('black-forest-labs/FLUX-2-dev').key).toBe('flux.2-dev')
+ expect(identifyModel('FLUX.2 [klein] 4B Base').key).toBe('flux.2-klein-4b')
+ })
+
+ it('does NOT confuse variants that cost different amounts', () => {
+ // The failure that would matter. schnell is US$ 0.0005 on DeepInfra and dev
+ // is US$ 0.009 — eighteen times more. Collapsing them would be a lie with a
+ // price tag on it.
+ const schnell = identifyModel('FLUX.1 [schnell]')
+ const dev = identifyModel('FLUX.1 [dev]')
+ const kontext = identifyModel('black-forest-labs/FLUX.1-Kontext-dev')
+
+ expect(schnell.key).not.toBe(dev.key)
+ expect(dev.key).not.toBe(kontext.key)
+ expect(kontext.key).toBe('flux.1-kontext')
+ })
+
+ it('does not let a general rule swallow a more specific one', () => {
+ // `flux.2` would match "FLUX.2 [klein]" too. Order in the rule table is what
+ // stops that, and this is the test that fails if somebody reorders it.
+ expect(identifyModel('FLUX.2 [klein] 9B').key).toBe('flux.2-klein-9b')
+ expect(identifyModel('FLUX.2 [flex]').key).toBe('flux.2-flex')
+ expect(identifyModel('FLUX.2 Pro').key).toBe('flux.2-pro')
+ })
+
+ it('strips the task from the name, because a task is not a model', () => {
+ // fal lists the same weights under several endpoints. Left in, "Text to
+ // Image" and "Image to Image" would split one model into rows that cannot be
+ // compared to anything.
+ const t2i = identifyModel('Ideogram V4.0 Text to Image', 'fal-ai/ideogram/v4')
+ const i2i = identifyModel('Ideogram V4.0q Image to Image', 'fal-ai/ideogram/v4/image-to-image')
+
+ expect(t2i.key).toBe('ideogram-v4')
+ expect(i2i.key).toBe(t2i.key)
+ })
+
+ it('refuses the match when a qualifier is left over', () => {
+ // A guarda que existe porque a tela chegou a exibir estas três mentiras com
+ // número: fal `Qwen Image` US$ 20 contra DeepInfra `Qwen-Image-Max` US$ 75,
+ // anunciado como "3,8× mais caro" sobre dois modelos diferentes.
+ expect(identifyModel('Qwen Image').key).toBe('qwen-image')
+ expect(identifyModel('Qwen/Qwen-Image-Max').matchedBy).toBe('unresolved')
+ expect(identifyModel('FLUX 2 Pro Outpaint').matchedBy).toBe('unresolved')
+ expect(identifyModel('Ideogram V4.0q Text to Image (LoRA)').matchedBy).toBe('unresolved')
+ })
+
+ it('does not let the guard reject a name the rule fully explains', () => {
+ // A guarda olha só o que SOBRA depois do casamento. `edit` é qualificador e
+ // ainda assim Qwen-Image Edit casa, porque a regra do edit consumiu a palavra.
+ expect(identifyModel('Qwen-Image Edit').key).toBe('qwen-image-edit')
+ expect(identifyModel('SDXL Turbo').key).toBe('sdxl-turbo')
+ expect(identifyModel('FLUX.2 [klein] 4B Base').key).toBe('flux.2-klein-4b')
+ expect(identifyModel('black-forest-labs/FLUX-2-klein-4b').key).toBe('flux.2-klein-4b')
+ })
+
+ it('separa as variantes de FLUX.2 que um coringa juntava', () => {
+ // `/flux.*2/` engolia tudo: `FLUX 2 Lora` (fal, US$ 21) e `FLUX-2-max`
+ // (DeepInfra, US$ 100) saíram na mesma linha dizendo "4,8× mais caro".
+ const chaves = [
+ identifyModel('black-forest-labs/FLUX-2-max').key,
+ identifyModel('FLUX 2 Flash').key,
+ identifyModel('FLUX 2 Turbo').key,
+ identifyModel('FLUX 2 Lora Gallery Realism').key,
+ ]
+
+ expect(new Set(chaves).size).toBe(chaves.length)
+ expect(chaves[0]).toBe('flux.2-max')
+ expect(identifyModel('FLUX 2 Lora Gallery Realism').matchedBy).toBe('unresolved')
+ })
+
+ it('reads the endpoint id when the display name says nothing useful', () => {
+ // fal titles some models "V4.0q [fast]", which identifies nothing on its own.
+ const bare = identifyModel('V4.0q [fast]', 'fal-ai/ideogram/v4')
+ expect(bare.key).toBe('ideogram-v4')
+ })
+
+ it('matches the other families that appear at more than one provider', () => {
+ expect(identifyModel('Nano Banana Pro').key).toBe('nano-banana-pro')
+ expect(identifyModel('google/nano-banana-2').key).toBe('nano-banana-2')
+ expect(identifyModel('Qwen-Image Edit').key).toBe('qwen-image-edit')
+ expect(identifyModel('Qwen/Qwen-Image').key).toBe('qwen-image')
+ expect(identifyModel('stabilityai/sdxl-turbo').key).toBe('sdxl-turbo')
+ expect(identifyModel('ByteDance/Seedream-4').key).toBe('seedream-4')
+ })
+
+ it('leaves an unknown model alone instead of guessing', () => {
+ // No match is a good answer: the model keeps its own row, which is what the
+ // table does today and is never wrong.
+ const unknown = identifyModel('PrunaAI/p-image')
+
+ expect(unknown.matchedBy).toBe('unresolved')
+ expect(unknown.label).toBe('PrunaAI/p-image')
+ expect(unknown.maker).toBeNull()
+
+ // And two different unknowns never collapse into each other.
+ expect(unknown.key).not.toBe(identifyModel('deepseek-ai/Janus-Pro-1B').key)
+ })
+
+ it('is stable: the same name always yields the same key', () => {
+ for (const name of ['FLUX.1 [schnell]', 'PrunaAI/p-image', 'Recraft V3']) {
+ expect(identifyModel(name).key).toBe(identifyModel(name).key)
+ }
+ })
+
+ it('offers a filter list of real families, not of raw strings', () => {
+ const families = knownFamilies()
+
+ expect(families.length).toBeGreaterThan(5)
+ expect(families.map((f) => f.key)).toContain('flux')
+ expect(families.every((f) => f.label !== '' && f.maker !== '')).toBe(true)
+ })
+})
diff --git a/apps/web/package.json b/apps/web/package.json
index d225635..4d5979f 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -10,6 +10,8 @@
"clean": "rm -rf dist *.tsbuildinfo"
},
"dependencies": {
+ "@fontsource-variable/ibm-plex-sans": "^5.3.0",
+ "@fontsource/ibm-plex-mono": "^5.3.0",
"@orpc/client": "^1.15.0",
"@orpc/openapi-client": "^1.15.0",
"@orpc/tanstack-query": "^1.15.0",
diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx
index 295f900..2108615 100644
--- a/apps/web/src/app.tsx
+++ b/apps/web/src/app.tsx
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { api, formatUsd, newCommandId } from './api.js'
import { Credentials } from './credentials.js'
-import { PriceIndex } from './index-de-precos.js'
+import { Indice } from './indice.js'
/**
* The shape of the product, in the order a person actually needs it.
@@ -58,7 +58,7 @@ export function App() {
{screen === 'index' ? (
-
+
) : user ? (
) : (
@@ -100,7 +100,7 @@ function Settings({ user }: { user: { email: string; role: string; timezone: str
@@ -194,7 +194,7 @@ function SignedOut({ registrationOpen }: { registrationOpen: boolean }) {
required
value={email}
onChange={(event) => setEmail(event.target.value)}
- className="w-full rounded border border-(--color-line) bg-(--color-ground) px-3 py-2 text-sm"
+ className="w-full rounded-lg border border-(--color-edge) bg-(--color-ground) px-3 py-2.5 text-sm"
/>
@@ -207,7 +207,7 @@ function SignedOut({ registrationOpen }: { registrationOpen: boolean }) {
minLength={12}
value={password}
onChange={(event) => setPassword(event.target.value)}
- className="w-full rounded border border-(--color-line) bg-(--color-ground) px-3 py-2 text-sm"
+ className="w-full rounded-lg border border-(--color-edge) bg-(--color-ground) px-3 py-2.5 text-sm"
/>
Mínimo de 12 caracteres.
@@ -277,12 +277,12 @@ function BudgetRow({
aria-label={`Novo teto em dólares para ${budget.period === 'month' ? 'o mês' : 'o dia'}`}
value={draft}
onChange={(event) => setDraft(event.target.value)}
- className="w-24 rounded border border-(--color-line) bg-(--color-ground) px-2 py-1 text-right font-mono text-sm tabular-nums"
+ className="w-24 min-h-10 rounded-lg border border-(--color-edge) bg-(--color-ground) px-2 text-right font-mono text-sm tabular-nums"
/>
@@ -303,11 +303,14 @@ function Tab({
return (