diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 9cbab25..5be9bbb 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -37,8 +37,12 @@ jobs: - uses: actions/dependency-review-action@v5 with: fail-on-severity: high - # Direct or transitive, in any version. - deny-packages: axios + # Direct or transitive, in any version. It has to be a package-URL: + # written as a bare `axios` the action fails parsing its own input + # ("package-url must start with pkg:") and dies before reviewing a + # single dependency — a red step that checked nothing, which is the + # same failure the lychee config had. + deny-packages: pkg:npm/axios # Copyleft licenses are denied on the code side: Prumo ships under # Apache-2.0 and a self-hoster must be able to run it without # inheriting a distribution obligation they did not choose. The price diff --git a/PLANO.md b/PLANO.md index 080d7ba..f8f2dad 100644 --- a/PLANO.md +++ b/PLANO.md @@ -3,9 +3,22 @@ > Fio de prumo: a ferramenta que prova que a parede está reta. > Aqui, a que prova que o preço está certo — e que a imagem saiu pelo menor preço. -**Estado:** **M0 fechado, M1 de pé, CI verde.** Portão provado, esqueleto vertical -rodando contra Postgres de verdade, nenhum adaptador de provedor e nenhuma geração -ainda. Próximo: M2 (cofre de chaves). +**Estado:** **M0, M1 e M2 fechados. O índice de preço está no ar, público, sem +conta e sem chave.** Portão provado, Postgres de verdade, cofre com envelope +AES-256-GCM e RLS de papel restrito, três coletores (fal, DeepInfra, OpenRouter) +e 648 modelos no catálogo. **Nenhuma geração ainda** — é o próximo marco. + +O índice foi antecipado a pedido, fora da ordem da tabela abaixo (lá ele é M6): +sem ver o preço de tudo, ninguém sabe de qual provedor vale a pena colar a chave. +A tela usa o rótulo **M3** por causa disso; a tabela de marcos guarda a ordem +original, e a diferença é deliberada, não deriva. + +**Onde a comparação entre provedores está hoje:** três variantes de modelo têm +preço em mais de um provedor — FLUX.2 [klein] 4B (1,6×), 9B (1,4×) e [pro] +(2,0×). Três é pouco e é o número honesto: dez dos treze provedores não publicam +preço legível por máquina, e a regra de identidade recusa todo casamento que ela +não consegue explicar. Ela chegou a dizer doze, e nove daqueles comparavam +modelos diferentes. **Legenda:** ✅ provado · 🧪 decidido mas não medido · 🔴 bloqueia código · ⬜ planejado --- 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/app/credentials.ts b/apps/server/src/app/credentials.ts index 8342c0d..8380424 100644 --- a/apps/server/src/app/credentials.ts +++ b/apps/server/src/app/credentials.ts @@ -24,6 +24,17 @@ export interface AddedCredential { readonly verification: VerifyOutcome } +/** + * `23505` é `unique_violation` no PostgreSQL, e é o único erro que significa + * "essa chave já está aí". Comparar pelo código do driver, e não pela mensagem, + * porque a mensagem muda com o idioma do servidor (`lc_messages`). + */ +function isUniqueViolation(cause: unknown): boolean { + return ( + typeof cause === 'object' && cause !== null && (cause as { code?: unknown }).code === '23505' + ) +} + const fail = { unknownProvider: (slug: string) => new AppError('UNKNOWN_PROVIDER', `No provider named ${slug}`, { provider: slug }), @@ -95,8 +106,29 @@ export function createCredentials(deps: CredentialDeps) { const lastFour = vault.lastFour(secret) const credential = await uow.run({ kind: 'user', userId: user.id }, async (repos) => { + /* + * O mesmo comando duas vezes é a MESMA resposta, não um erro. + * + * Isto respondia CREDENTIAL_DUPLICATE, que afirma um fato errado sobre a + * chave da pessoa: um clique duplo ou um cliente que reenviou depois de um + * timeout ouvia "você já adicionou essa chave" e parava de tentar. É a + * mesma decisão que `auth.ts` já tomava para o registro. + * + * Reverificar não é efeito colateral — é leitura, não cobrança —, então o + * caminho de verificação lá embaixo roda normalmente. + */ const replay = await repos.commands.find(input.commandId) - if (replay) throw fail.duplicate(provider.slug) + if (replay) { + const jaCriada = (replay.result as { credentialId?: string } | null)?.credentialId + const existente = jaCriada + ? (await repos.credentials.listForUser(user.id)).find((c) => c.id === jaCriada) + : undefined + + if (existente) return existente + // O comando rodou e a credencial que ele criou não está mais aqui — + // revogada, provavelmente. Repetir o insert violaria a chave do comando. + throw fail.notFound() + } let created: CredentialSummary try { @@ -115,6 +147,14 @@ export function createCredentials(deps: CredentialDeps) { // The partial unique index on (user_id, provider, fingerprint) is what // actually decides, not a prior SELECT. Two simultaneous submissions of // the same key resolve here rather than racing. + // + // ONLY 23505. This used to catch everything and call it a duplicate, so a + // serialization failure, a dropped connection or a constraint nobody + // expected all reached the user as "you already added this key" — and + // somebody who believes that does not retry, so the key is never stored. + // It also hid the real cause from CI, which reported a duplicate for a + // fresh user and a fresh key on a fresh database. + if (!isUniqueViolation(cause)) throw cause throw new AppError('CREDENTIAL_DUPLICATE', undefined, { provider: provider.slug, cause }) } @@ -140,8 +180,19 @@ export function createCredentials(deps: CredentialDeps) { }) // Outside the transaction, on purpose. + // + // `credential.id`, nunca o `id` gerado acima: num replay a transação devolve a + // credencial que JÁ existia, com outro id, e anotar o id novo escreveria a + // verificação numa linha que não existe — em silêncio, porque um UPDATE que + // não acha nada não é erro. const verification = await verifier.verify({ provider: provider.slug, secret }) - const annotated = await applyVerification(user, id, provider.slug, verification, input.ipHash) + const annotated = await applyVerification( + user, + credential.id, + provider.slug, + verification, + input.ipHash, + ) return { credential: annotated ?? credential, verification } } 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/credentials.test.ts b/apps/server/tests/credentials.test.ts index 9487dbc..5f5a82e 100644 --- a/apps/server/tests/credentials.test.ts +++ b/apps/server/tests/credentials.test.ts @@ -59,9 +59,18 @@ afterAll(async () => { await adminPool.end().catch(() => undefined) }) +/* + * Sem Postgres, o teste é PULADO — nunca aprovado. + * + * `return` cedo faz o vitest reportar "passed" para um corpo que não asseriu + * nada: quem roda sem banco vê a suíte inteira verde e acredita que a camada de + * dados foi verificada. É a mesma doença que a config do lychee e o + * dependency-review tinham — um passo que parece guardar algo e não guarda. + * `ctx.skip()` marca a linha como pulada, e a contagem final denuncia. + */ const run = (name: string, fn: () => Promise) => - it(name, async () => { - if (!reachable) return + it(name, async (ctx) => { + if (!reachable) ctx.skip(`sem Postgres em DATABASE_URL_TEST — este teste não rodou`) await fn() }) @@ -160,6 +169,33 @@ describe('the key vault, against a real database', () => { await expect(add()).rejects.toThrow(AppError) }) + run('answers a repeated command with the same credential, not with an error', async () => { + // Um clique duplo, ou um cliente que reenvia depois de um timeout, manda o + // MESMO commandId. Isso respondia CREDENTIAL_DUPLICATE — um fato errado sobre + // a chave da pessoa, que a fazia parar de tentar. Idempotência é devolver a + // mesma resposta, e é o que `auth.ts` já fazia no registro. + const user = await makeUser() + const commandId = ids.next() + const enviar = () => + credentials.add(user, { + commandId, + provider: 'replicate', + secret: `${SECRET}-idem`, + label: 'a mesma', + ipHash: null, + }) + + const primeira = await enviar() + const segunda = await enviar() + + expect(segunda.credential.id).toBe(primeira.credential.id) + expect(await credentials.list(user)).toHaveLength(1) + + // E a verificação foi anotada na credencial que existe, não no id novo que o + // segundo envio gerou e descartou. + expect(segunda.credential.verifiedAt).not.toBeNull() + }) + run("does not let one user see, verify or revoke another user's key", async () => { const alice = await makeUser() const bob = await makeUser() diff --git a/apps/server/tests/database.test.ts b/apps/server/tests/database.test.ts index 5bee5f1..5a9e45a 100644 --- a/apps/server/tests/database.test.ts +++ b/apps/server/tests/database.test.ts @@ -85,9 +85,18 @@ afterAll(async () => { await adminPool.end().catch(() => undefined) }) +/* + * Sem Postgres, o teste é PULADO — nunca aprovado. + * + * `return` cedo faz o vitest reportar "passed" para um corpo que não asseriu + * nada: quem roda sem banco vê a suíte inteira verde e acredita que a camada de + * dados foi verificada. É a mesma doença que a config do lychee e o + * dependency-review tinham — um passo que parece guardar algo e não guarda. + * `ctx.skip()` marca a linha como pulada, e a contagem final denuncia. + */ const run = (name: string, fn: () => Promise) => - it(name, async () => { - if (!reachable) return + it(name, async (ctx) => { + if (!reachable) ctx.skip(`sem Postgres em DATABASE_URL_TEST — este teste não rodou`) await fn() }) 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 ( diff --git a/apps/web/src/index-de-precos.tsx b/apps/web/src/index-de-precos.tsx deleted file mode 100644 index e8161ca..0000000 --- a/apps/web/src/index-de-precos.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { useMemo, useState } from 'react' -import { api } from './api.js' - -/** - * The price index, and the first thing anybody sees. - * - * No account, no key. Handing a service an API key is a decision, and nobody - * should have to make it before seeing a single price — which is exactly what the - * previous version of this screen asked for, and why it was wrong. - * - * One table with every combination of model and provider, cheapest first, because - * the fact worth paying for is that the SAME model costs different amounts in - * different places. - */ - -type Entry = Awaited>['entries'][number] - -export function PriceIndex() { - const [provider, setProvider] = useState('') - const [search, setSearch] = useState('') - const [ceiling, setCeiling] = useState('') - const [showUnpriced, setShowUnpriced] = useState(false) - - const catalog = useQuery({ - queryKey: ['catalog'], - queryFn: () => api.catalog.index({ width: 1024, height: 1024, steps: 25 }), - staleTime: 5 * 60_000, - }) - - const entries = catalog.data?.entries ?? [] - - const providers = useMemo(() => { - const names = new Map() - for (const entry of entries) names.set(entry.provider, entry.providerName) - return [...names.entries()].sort((a, b) => a[1].localeCompare(b[1])) - }, [entries]) - - const filtered = useMemo(() => { - const needle = search.trim().toLowerCase() - const cap = ceiling.trim() === '' ? null : Number(ceiling) * 1e9 - - return entries.filter((entry) => { - if (provider !== '' && entry.provider !== provider) return false - if (needle !== '' && !`${entry.name} ${entry.family ?? ''}`.toLowerCase().includes(needle)) { - return false - } - if (entry.costNanoUsd === null) return showUnpriced && cap === null - if (cap !== null && Number(entry.costNanoUsd) > cap) return false - return true - }) - }, [entries, provider, search, ceiling, showUnpriced]) - - const priced = entries.filter((e) => e.costNanoUsd !== null).length - - return ( -
-
-

Preço por imagem, 1024×1024

-

- Todos os provedores e modelos, do mais barato para o mais caro, sem precisar de conta nem - de chave. Coletado das APIs públicas de cada um — cada linha diz de onde veio e quando. - Você põe chave só nos que valerem a pena. -

-
- -
- - - - - - - -
- - {catalog.isPending ? ( -

Carregando o índice…

- ) : catalog.isError ? ( -

- Não deu para carregar o índice. O servidor respondeu com erro. -

- ) : ( - <> -

- {filtered.length} de {entries.length} linhas · {priced} com preço comparável ·{' '} - {entries.length - priced} sem preço publicado pelo provedor -

- -
- - - - - - - - - - - - {filtered.slice(0, 300).map((entry) => ( - - ))} - -
US$ / imagemProvedorModeloComo é cobradoFonte
-
- - {filtered.length > 300 && ( -

- Mostrando as 300 primeiras de {filtered.length}. Filtre para chegar no que interessa. -

- )} - - {catalog.data && catalog.data.providersWithoutMachineReadablePrices.length > 0 && ( -

- O que ainda não está aqui. Estes - provedores não publicam preço em nenhum formato que uma máquina leia:{' '} - {catalog.data.providersWithoutMachineReadablePrices.join(', ')}. Os preços deles - entram à mão, por pull request, com a URL da fonte e a data. Uma tabela que mostra só - o que é fácil de coletar se lê como “estes são todos os preços que existem”. -

- )} - - )} -
- ) -} - -function Th({ children, className = '' }: { children: React.ReactNode; className?: string }) { - return ( - - {children} - - ) -} - -function Row({ entry }: { entry: Entry }) { - // Always six decimals. A price column where one row shows 0.000200 and the next - // shows 0.0100 cannot be scanned down: the eye compares digit positions, not - // values, and the cheapest row stops looking cheapest. - const price = entry.costNanoUsd === null ? null : (Number(entry.costNanoUsd) / 1e9).toFixed(6) - - return ( - - - {price === null ? ( - // Never a blank cell: an empty price reads as "free" or as a bug, and - // the reason is the useful part. - - não comparável - - ) : ( - {price} - )} - - {entry.provider} - - {entry.name} - {entry.watermark === 'synthid' && ( - - SynthID - - )} - - - {entry.basis ?? '—'} - {entry.note && {entry.note}} - - - {entry.collectedAt ? ( - <> - {entry.method} · {new Date(entry.collectedAt).toLocaleDateString('pt-BR')} - {!entry.fresh && ( - - acima de 30 dias — fora do ranking - - )} - - ) : ( - '—' - )} - - - ) -} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f99e87b..898a56e 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,5 +1,21 @@ @import 'tailwindcss'; +/* + * Self-hosted, not Google Fonts. A stylesheet fetched from a third party on every + * page load is a request this project does not need to make on somebody else's + * behalf, and a self-hosted instance behind a firewall would silently fall back + * to system-ui. + * + * IBM Plex, not Inter. Inter is the default answer for any dashboard and would + * make this look like every other one; Plex was drawn for engineering + * documentation, its mono has real tabular figures, and the pairing carries the + * instrument metaphor the project is named after. The variable weight of the sans + * costs one file for the whole range. + */ +@import '@fontsource-variable/ibm-plex-sans'; +@import '@fontsource/ibm-plex-mono/400.css'; +@import '@fontsource/ibm-plex-mono/500.css'; + /** * Without this line `dark:` simply does not work in Tailwind 4. It is the single * most expensive missing line in a v3-to-v4 migration, because everything still @@ -16,10 +32,30 @@ */ --color-ground: oklch(0.185 0.005 258); --color-surface: oklch(0.225 0.006 258); - --color-line: oklch(0.31 0.008 258); + --color-raised: oklch(0.262 0.007 258); + + /* + * TWO border tokens, and the split is an accessibility requirement rather than + * a preference. + * + * WCAG 2.2 SC 1.4.11 asks for 3:1 on the visual information needed to identify + * a user interface component. The old single `--color-line` measured 1.42:1 + * against the page and 1.30:1 against a panel — it was the only thing saying + * where an input began, and at low brightness or in sunlight the filters read + * as text floating in the dark. + * + * `--color-edge` is measured at 3.38:1 / 3.11:1 and belongs on anything a + * person operates: input, select, checkbox, button, tab. + * `--color-line` stays quiet and is for structure a component does not depend + * on — the rule between two table rows, a divider inside a panel. + */ + --color-edge: oklch(0.52 0.008 258); + --color-line: oklch(0.34 0.008 258); + --color-ink: oklch(0.94 0.004 258); - --color-ink-muted: oklch(0.68 0.008 258); + --color-ink-muted: oklch(0.7 0.008 258); --color-brass: oklch(0.78 0.11 82); + --color-brass-dim: oklch(0.44 0.06 82); /* Semantic state colours. Every state also carries an icon or a word: colour alone never communicates state — around 8% of men have some colour vision @@ -31,6 +67,9 @@ --color-state-failed: oklch(0.7 0.16 25); --color-state-capped: oklch(0.78 0.13 75); --color-state-doubtful: oklch(0.74 0.12 320); + + --font-sans: 'IBM Plex Sans Variable', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Consolas, monospace; } @layer base { @@ -42,15 +81,20 @@ body { background: var(--color-ground); color: var(--color-ink); - font-family: - ui-sans-serif, - system-ui, - -apple-system, - 'Segoe UI', - sans-serif; + font-family: var(--font-sans); -webkit-font-smoothing: antialiased; } + /* + * Every digit the same width, everywhere, without asking each component to + * remember. In a price column the eye compares digit POSITION, and proportional + * figures make the comparison wrong before it starts. + */ + .font-mono, + table { + font-variant-numeric: tabular-nums; + } + /* * Tailwind 4 removed `cursor: pointer` from preflight and shadcn does not put it * back, so nothing clickable changes the cursor until somebody notices. These @@ -73,5 +117,51 @@ :focus-visible { outline: 2px solid var(--color-brass); outline-offset: 2px; + border-radius: 2px; + } + + /* Someone who asked their system to stop moving things means it. */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } +} + +@layer components { + /* + * The price bar: magnitude you can see without reading a digit. + * + * Prices span four orders of magnitude — US$ 0.0002 to US$ 0.15, a factor of + * 750. No numeric format renders that difference: `0.000200` and `0.150000` + * occupy the same width and the cheapest row stops looking cheapest. Length is + * the one channel that carries magnitude pre-attentively, and a log scale is + * what keeps 750× inside one column. + * + * A `
` with a width, so it costs zero kilobytes — which is what the + * project's refusal of a charting library requires. + */ + .barra-preco { + height: 3px; + border-radius: 999px; + background: var(--color-brass-dim); + min-width: 2px; + } + + /* + * A table header that stays put. Scrolling past thirty rows without it means + * five columns of similar-looking numbers and dates with nothing saying which + * is which. + */ + .cabecalho-fixo { + position: sticky; + top: 0; + z-index: 1; + background: var(--color-surface); } } diff --git a/apps/web/src/indice.tsx b/apps/web/src/indice.tsx new file mode 100644 index 0000000..c7c617e --- /dev/null +++ b/apps/web/src/indice.tsx @@ -0,0 +1,846 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' +import { api } from './api.js' +import { + formatarPorMil, + formatarUnitario, + formatarVezes, + custoMensal, + larguraDaBarra, + nomeDaTarefa, + quantasVezes, +} from './preco.js' + +/** + * O índice de preços. + * + * A tela anterior era uma planilha com CSS: 648 linhas planas, o identificador na + * terceira coluna, seis casas decimais em toda célula, e a razão de existir do + * produto invisível. Quatro coisas mudam isso, e três delas não são visuais: + * + * 1. **O modelo é a entidade, o provedor é a oferta.** `FLUX.1 [schnell]` no fal + * e `black-forest-labs/FLUX-1-schnell` na DeepInfra viram uma linha só, que + * diz "a partir de X, em N provedores, o mais caro é 3,2× o mais barato". + * Sem isso a pergunta que o produto existe para responder — onde eu rodo ESTE + * modelo mais barato — não tem onde ser feita. + * 2. **A unidade é mil imagens.** Ver `preco.ts`. + * 3. **A procedência vira link.** A URL da página de preço do provedor já era + * coletada, já viajava no contrato, e era jogada fora. Era a única coisa que + * fechava o laço de confiança. + * 4. **A tarefa aparece.** Um modelo de inpaint sentava na mesma lista, no mesmo + * preço, que um texto→imagem, e nada avisava que ele não roda a partir de um + * prompt. + */ + +type Entry = Awaited>['entries'][number] + +interface Grupo { + readonly chave: string + readonly titulo: string + readonly maker: string | null + readonly ofertas: Entry[] + readonly maisBarata: Entry | null + readonly maisCara: Entry | null + readonly provedores: number + /** Vale abrir: mais de uma oferta, em mais de um provedor. */ + readonly agrupado: boolean + /** + * Dá para comparar DE VERDADE: dois provedores que publicaram preço. + * + * Não é o mesmo que `agrupado`, e a diferença é o que separa uma promessa + * cumprida de uma quebrada. FLUX.1 [schnell] está em dois provedores e só um + * deles publica preço — abrir a linha é útil, mas prometer uma comparação de + * preço ali é prometer o que não existe. + */ + readonly comparavel: boolean + /** + * Quanto o provedor mais caro cobra em cima do mais barato, cada um no MELHOR + * preço que ele publica. + * + * Antes isso era a razão entre a oferta mais barata e a mais cara do grupo, sem + * olhar de quem eram — e o fal publica o mesmo modelo em várias rotas com preços + * diferentes. A tela dizia "o mais caro custa 2,2×" comparando fal com fal, numa + * coluna intitulada "provedores". Era uma frase verdadeira sobre números e falsa + * sobre o que ela parecia dizer. + */ + readonly fator: number +} + +const IMAGENS_POR_MES = 1000 + +export function Indice() { + const [provedor, setProvedor] = useState('') + const [busca, setBusca] = useState('') + const [buscaEcoada, setBuscaEcoada] = useState('') + const [mostrarSemPreco, setMostrarSemPreco] = useState(false) + const [soComparaveis, setSoComparaveis] = useState(false) + const [aberto, setAberto] = useState(null) + + const catalogo = useQuery({ + queryKey: ['catalog'], + queryFn: () => api.catalog.index({ width: 1024, height: 1024, steps: 25 }), + staleTime: 5 * 60_000, + }) + + const entries = useMemo(() => catalogo.data?.entries ?? [], [catalogo.data]) + + /* + * O contador é anunciado depois que a digitação para. + * + * `aria-live` ligado direto no estado da busca enfileira um anúncio por tecla: + * digitar "flux" faz o leitor de tela falar quatro números por cima da própria + * digitação, e a fila é FIFO — não descarta o que já venceu. Meio segundo de + * silêncio transforma quatro anúncios em um. + */ + useEffect(() => { + const timer = setTimeout(() => setBuscaEcoada(busca), 500) + return () => clearTimeout(timer) + }, [busca]) + + const grupos = useMemo(() => agrupar(entries), [entries]) + + const filtrados = useMemo(() => { + const agulha = busca.trim().toLowerCase() + + return grupos.filter((g) => { + // O filtro que corresponde à razão de existir do produto. Sem ele, os + // modelos com preço em dois provedores — a única resposta para "onde rodo + // ESTE modelo mais barato" — ficam dispersos entre oitenta e uma linhas, e a + // manchete anuncia um número que a pessoa não tem como isolar. + if (soComparaveis && !g.comparavel) return false + + if (provedor !== '' && !g.ofertas.some((o) => o.provider === provedor)) return false + + if (agulha !== '') { + const palheiro = `${g.titulo} ${g.maker ?? ''} ${g.ofertas.map((o) => o.name).join(' ')}` + // Pontuação fora: buscar "flux 2" precisa achar `FLUX-2-pro`, e cada + // provedor pontua o mesmo nome de um jeito. + if (!semPontuacao(palheiro).includes(semPontuacao(agulha))) return false + } + + if (g.maisBarata === null) return mostrarSemPreco + return true + }) + }, [grupos, provedor, busca, mostrarSemPreco, soComparaveis]) + + const comPreco = grupos.filter((g) => g.maisBarata !== null) + + const faixa = useMemo(() => { + const valores = comPreco.map((g) => Number(g.maisBarata?.costNanoUsd ?? 0)).filter((v) => v > 0) + return valores.length > 0 + ? { min: Math.min(...valores), max: Math.max(...valores) } + : { min: 1, max: 1 } + }, [comPreco]) + + const contagemPorProvedor = useMemo(() => { + const mapa = new Map() + for (const entry of entries) { + const atual = mapa.get(entry.provider) ?? { nome: entry.providerName, total: 0 } + atual.total += 1 + mapa.set(entry.provider, atual) + } + return [...mapa.entries()].sort((a, b) => b[1].total - a[1].total) + }, [entries]) + + if (catalogo.isPending) return + if (catalogo.isError) return + + const comparaveis = comPreco.length + const disputados = grupos.filter((g) => g.comparavel).length + + /* + * O denominador é o que a lista mostraria SEM filtro, não o total do banco. + * "12 de 413" com a lista sem filtro dizendo 81 faz a pessoa procurar por um + * erro que não existe: 413 conta os grupos sem preço, que ela nunca viu. + */ + const universo = mostrarSemPreco ? grupos.length : comparaveis + + return ( +
+ + + + +

+ {buscaEcoada === busca && ( + <> + {filtrados.length}{' '} + {filtrados.length === 1 ? 'modelo' : 'modelos'} + {provedor !== '' || busca !== '' || soComparaveis ? ` de ${universo}` : ''} + + )} +

+ + {filtrados.length === 0 ? ( + { + setProvedor('') + setBusca('') + setSoComparaveis(false) + }} + /> + ) : ( + <> + {/* Acima de md: tabela. Abaixo: cartão. Cinco colunas em 375px viram + 672px de rolagem horizontal, e a coluna que explica o preço fica a + 337px de distância da coluna do preço — a cor sozinha passa a ser a + única marca de que o dado envelheceu, o que é a 1.4.1 falhando. */} + + + + )} + + +
+ ) +} + +// --- a manchete ---------------------------------------------------------------- + +function Manchete({ + modelos, + comparaveis, + disputados, + maisBarato, +}: { + modelos: number + comparaveis: number + disputados: number + maisBarato: Grupo | null +}) { + return ( +
+
+

+ Quanto custa mil imagens, em cada lugar que gera +

+

+ O mesmo modelo custa preços diferentes em provedores diferentes. Esta tabela mostra a + diferença — sem conta e sem chave. Cada preço carrega de onde veio e de quando é. +

+
+ +
+ + + + +
+
+ ) +} + +function Numero({ + rotulo, + valor, + destaque = false, +}: { + rotulo: string + valor: string + destaque?: boolean +}) { + return ( +
+
+ {valor} +
+
{rotulo}
+
+ ) +} + +// --- filtros ------------------------------------------------------------------- + +function Filtros({ + provedor, + aoTrocarProvedor, + busca, + aoBuscar, + mostrarSemPreco, + aoAlternarSemPreco, + soComparaveis, + aoAlternarComparaveis, + comparaveisEntreProvedores, + provedores, + semPreco, +}: { + provedor: string + aoTrocarProvedor: (v: string) => void + busca: string + aoBuscar: (v: string) => void + mostrarSemPreco: boolean + aoAlternarSemPreco: (v: boolean) => void + soComparaveis: boolean + aoAlternarComparaveis: (v: boolean) => void + comparaveisEntreProvedores: number + provedores: ReadonlyArray + semPreco: number +}) { + return ( +
+ + + {/* Chips com a contagem dentro. Quem escolhe já sabe o tamanho do resultado, + e nenhum filtro devolve zero de surpresa. */} +
+ aoTrocarProvedor('')}> + todos + + {comparaveisEntreProvedores > 0 && ( + aoAlternarComparaveis(!soComparaveis)}> + dá para comparar{' '} + {comparaveisEntreProvedores} + + )} + {provedores.map(([slug, { nome, total }]) => ( + aoTrocarProvedor(slug)}> + {nome} {total} + + ))} + {semPreco > 0 && ( + aoAlternarSemPreco(!mostrarSemPreco)}> + sem preço publicado {semPreco} + + )} +
+
+ ) +} + +/** 40px de altura: a 2.5.8 exige 24, e um dedo em pé no ônibus precisa de mais. */ +function Chip({ + ativo, + aoClicar, + children, +}: { + ativo: boolean + aoClicar: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +// --- a tabela ------------------------------------------------------------------ + +function Tabela({ + grupos, + faixa, + aberto, + aoAbrir, +}: { + grupos: Grupo[] + faixa: { min: number; max: number } + aberto: string | null + aoAbrir: (v: string | null) => void +}) { + return ( +
+
+ + + + + + + + + + + + {grupos.slice(0, 200).map((g) => ( + aoAbrir(aberto === g.chave ? null : g.chave)} + /> + ))} + +
+ Modelos de geração de imagem e o preço de mil imagens em cada provedor, ordenados do + mais barato para o mais caro. +
+ Modelo + + Mil imagens ↑ + + Onde + + Procedência +
+
+ + {grupos.length > 200 && ( +

+ Mostrando 200 de {grupos.length}. Filtre para chegar no que interessa — o resto não é + escondido, só não cabe numa tela. +

+ )} +
+ ) +} + +function Linha({ + grupo, + faixa, + aberto, + aoAbrir, +}: { + grupo: Grupo + faixa: { min: number; max: number } + aberto: boolean + aoAbrir: () => void +}) { + const barata = grupo.maisBarata + + return ( + <> + + {/* O identificador é cabeçalho de linha, e vem primeiro. Numa tabela cuja + razão de existir é comparar o preço do MESMO modelo entre provedores, + navegar a coluna de preço e ouvir "0,20" sem saber de qual modelo é + não serve para nada. */} + + {grupo.titulo} + + {grupo.maker && {grupo.maker}} + {barata?.tasks.slice(0, 2).map((t) => ( + {nomeDaTarefa(t)} + ))} + {barata?.watermark === 'synthid' && marca d'água} + + + + + {barata?.costNanoUsd ? ( + <> + {formatarPorMil(barata.costNanoUsd)} +