Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions PLANO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/app/catalog.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
55 changes: 53 additions & 2 deletions apps/server/src/app/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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 {
Expand All @@ -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 })
}

Expand All @@ -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 }
}
Expand Down
Loading