Resposta curta
Para verificar se um relay da API do Claude envia mesmo suas requisições ao Claude, use a assinatura do pensamento estendido: devolva um bloco thinking assinado sem alterações (um backend genuíno da Anthropic aceita) e depois devolva-o com a assinatura alterada (um backend genuíno recusa com Invalid signature in thinking block). Perguntar ao modelo quem ele é, perguntar a data de corte do conhecimento ou ler o campo model não prova nada. Até o teste da assinatura só mostra que as requisições chegam ao Claude da Anthropic, não qual modelo Claude respondeu, então rode o teste com regularidade e nos modelos que você paga.
Este guia é publicado pela APIVAI, um gateway para os modelos Claude e GPT. Os testes funcionam com qualquer relay que suporte o formato nativo da Anthropic, inclusive o nosso; nossos resultados estão perto do fim.
Por que a troca de modelo preocupa
Comprar o Claude por meio de um revendedor, relay ou gateway significa confiar que uma requisição a claude-sonnet-4-6 chega ao Claude Sonnet 4.6. Várias fontes indicam que essa confiança muitas vezes não se justifica:
- Pesquisa. Um artigo do CISPA, "Real Money, Fake Models" (arXiv 2603.01919, aceito no ACM CCS 2026), auditou "APIs sombra" usadas em 187 artigos acadêmicos. Nos três serviços examinados a fundo, 45,83% das verificações de impressão digital do modelo não bateram com o modelo anunciado.
- Fóruns. Fóruns de desenvolvedores chineses têm palavras próprias para isso: 以次充好 (fazer passar o barato por caro), 掺水 (aguar), 降智 (o modelo "ficou mais burro"). Uma troca típica no V2EX: "Como eles garantem que não é outro modelo?" Resposta: "Não garantem, e ainda misturam."
- Marketplaces. No Hacker News, um usuário conta que foi roteado para versões muito quantizadas "pagando talvez 15% a menos".
A troca não é a única causa de "hoje está pior". A própria Anthropic publicou em 2025 uma análise de bugs de infraestrutura que pioraram parte das respostas do Sonnet, e prompts de sistema ou o tratamento do contexto também mudam as respostas. Por isso o teste precisa ser mecânico, não uma impressão.
Verificações que não provam nada
| Verificação | Por que falha |
|---|---|
| Perguntar "qual modelo você é?" | O relay pode injetar um prompt de sistema, e modelos genuínos também erram (num levantamento de 190 modelos, cerca de 60% responderam com um nome que não era o seu) |
| Perguntar a data de corte | Perguntamos dez vezes ao Claude Haiku 4.5 em 19/10/2026 e ele respondeu "April 2024" todas as vezes: é consistente, mas um relay pode fazer qualquer modelo responder o mesmo com um prompt de sistema |
Ler o campo model | Quem preenche é o relay |
| Julgar a qualidade das respostas | Ruído demais: regressões reais, prompts e contexto mudam a qualidade |
Como funciona o teste da assinatura de thinking
Com o pensamento estendido ativado, a Anthropic devolve cada bloco thinking com uma signature opaca. Numa conversa com chamadas de ferramenta, o turno anterior do assistente, blocos de thinking incluídos, precisa ser reenviado na próxima requisição, e a Anthropic verifica a assinatura (documentação do extended thinking). Uma assinatura falsa ou editada é recusada:
400 invalid_request_error: messages.1.content.0: Invalid `signature` in `thinking` block
O teste usa essa verificação em três requisições:
- Faça uma pergunta que leve o modelo a chamar uma ferramenta, com thinking ligado. A resposta traz um bloco
thinkingassinado e um blocotool_use. - Reenvie esse turno sem alterações, com o resultado da ferramenta. Um backend genuíno responde 200.
- Reenvie de novo com os últimos caracteres da assinatura trocados. Um backend genuíno responde 400 com o erro acima.
A terceira requisição é a prova. Um relay que serve outro modelo pode gerar uma sequência parecida com uma assinatura, mas não consegue fazer o verificador da Anthropic aceitar a assinatura real e recusar a editada, a menos que suas requisições cheguem de fato à Anthropic.
As mesmas respostas trazem indícios mais fracos que vale registrar: IDs de mensagem que começam com msg_, IDs de chamada de ferramenta que começam com toolu_, e objetos usage com service_tier e um objeto cache_creation aninhado. Cada um deles, isoladamente, pode ser forjado.
Rodar o teste uma vez
Python com requests, formato nativo da Anthropic. No Claude Haiku as três requisições custam uma pequena fração de centavo; no Sonnet, cerca de um centavo.
import os, requests
BASE = os.environ["RELAY_BASE_URL"] # e.g. https://your-relay.example (no /v1)
KEY = os.environ["RELAY_API_KEY"]
MODEL = os.environ.get("MODEL", "claude-sonnet-4-6")
H = {"x-api-key": KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"}
TOOLS = [{"name": "get_weather", "description": "Get the weather for a city",
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}]
THINK = {"type": "enabled", "budget_tokens": 1024}
Q = {"role": "user", "content": "What is the weather in Paris? Use the tool."}
def call(messages):
r = requests.post(f"{BASE}/v1/messages", headers=H, timeout=120, json={
"model": MODEL, "max_tokens": 1600, "thinking": THINK, "tools": TOOLS, "messages": messages})
return r.status_code, r.json()
s, a = call([Q])
blocks = a.get("content", [])
print("1st:", s, a.get("id"), [b["type"] for b in blocks])
print("usage fields:", sorted(a.get("usage", {}).keys()))
tool = next((b for b in blocks if b["type"] == "tool_use"), None)
think = next((b for b in blocks if b["type"] == "thinking"), None)
if not (tool and think and think.get("signature")):
raise SystemExit("No thinking block with a signature: cannot run the test (try another model or prompt).")
def second(content):
return call([Q, {"role": "assistant", "content": content},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": tool["id"], "content": "Sunny, 18C"}]}])
s2, b2 = second(blocks)
print("2nd, signature unchanged:", s2, "(expect 200)")
bad = [dict(b, signature=b["signature"][:-8] + "AAAAAAAA") if b["type"] == "thinking" else b for b in blocks]
s3, b3 = second(bad)
print("3rd, signature tampered:", s3, b3.get("error", {}).get("message", ""), "(expect 400 Invalid `signature`)")| Assinatura inalterada | Assinatura editada | O que significa |
|---|---|---|
| 200 | 400 "Invalid signature" | As requisições chegam ao Claude da Anthropic e o relay repassa os blocos de thinking intactos |
| 200 | 200 | Ninguém verifica as assinaturas: o backend não é a Anthropic |
| 400 | 400 | O relay quebra os blocos de thinking, muitas vezes porque converte as requisições para o formato da OpenAI; sessões de vários turnos do Claude Code também vão falhar |
Acompanhar um relay ao longo do tempo
Uma execução mostra um momento, e os relays mudam o roteamento. O monitor abaixo repete as mesmas três requisições para cada modelo informado, acrescenta uma linha JSON por verificação a um log, compara com a verificação anterior do mesmo modelo e envia uma mensagem no Telegram quando algo muda.
Ele registra quatro sinais:
| Sinal | O que uma mudança sugere |
|---|---|
| Veredito da assinatura | As requisições não chegam mais à Anthropic, ou os blocos de thinking começaram a ser quebrados |
| Número de tokens de entrada de uma requisição fixa | A mesma requisição ao mesmo modelo sempre dá o mesmo número. Outro número indica outro tokenizador (outra família de modelos) ou um prompt de sistema adicionado pelo relay |
Formato dos IDs e campos de usage | Outro canal por trás do relay |
| Latência | Sinal fraco: só é avisada se triplicar |
import json, os, re, time
import requests
BASE = os.environ["RELAY_BASE_URL"].rstrip("/")
KEY = os.environ["RELAY_API_KEY"]
MODELS = [m.strip() for m in os.environ.get("MODELS", "claude-haiku-4-5").split(",") if m.strip()]
LOG = os.environ.get("LOG_FILE", "relay_watch.jsonl")
HEADERS = {"x-api-key": KEY, "anthropic-version": "2023-06-01", "content-type": "application/json"}
TOOLS = [{"name": "get_weather", "description": "Get the weather for a city",
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}]
THINKING = {"type": "enabled", "budget_tokens": 1024}
QUESTION = {"role": "user", "content": "What is the weather in Paris? Use the tool."}
def post(model, messages):
t = time.time()
r = requests.post(f"{BASE}/v1/messages", headers=HEADERS, timeout=180, json={
"model": model, "max_tokens": 1600, "thinking": THINKING, "tools": TOOLS, "messages": messages})
try:
body = r.json()
except ValueError:
body = {}
return r.status_code, body, int((time.time() - t) * 1000)
def check(model):
res = {"ts": time.strftime("%Y-%m-%dT%H:%M:%S"), "model": model}
status, a, ms = post(model, [QUESTION])
res.update(status=status, latency_ms=ms)
if status != 200:
res["verdict"] = f"error {status}: {a.get('error', {}).get('message', '')[:120]}"
return res
blocks = a.get("content", [])
usage = a.get("usage", {})
tool = next((b for b in blocks if b.get("type") == "tool_use"), None)
think = next((b for b in blocks if b.get("type") == "thinking"), None)
res.update(
id_format=bool(re.match(r"^msg_[0-9A-Za-z]{20,}$", a.get("id", ""))),
tool_id_format=bool(tool and tool.get("id", "").startswith("toolu_")),
usage_fields=all(k in usage for k in ("service_tier", "cache_creation")),
# A mesma requisição ao mesmo modelo sempre dá o mesmo número de tokens de entrada.
# Outro número indica outro tokenizador (outro modelo) ou um prompt de sistema adicionado.
input_tokens=usage.get("input_tokens"),
)
if not (tool and think and think.get("signature")):
res["verdict"] = "no signed thinking block: signature test not possible"
return res
def second(content):
return post(model, [QUESTION, {"role": "assistant", "content": content},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": tool["id"], "content": "Sunny, 18C"}]}])
s2, _, _ = second(blocks)
bad = [dict(b, signature=b["signature"][:-8] + "AAAAAAAA") if b.get("type") == "thinking" else b for b in blocks]
s3, b3, _ = second(bad)
rejected = s3 == 400 and "signature" in json.dumps(b3).lower()
res.update(roundtrip=s2, tampered=s3, tamper_rejected=rejected)
if s2 == 200 and rejected:
res["verdict"] = "ok"
elif s2 == 200 and s3 == 200:
res["verdict"] = "SIGNATURES NOT CHECKED"
else:
res["verdict"] = f"thinking blocks broken ({s2}/{s3})"
return res
def last_runs():
prev = {}
if os.path.exists(LOG):
with open(LOG, encoding="utf-8") as f:
for line in f:
try:
r = json.loads(line)
prev[r["model"]] = r
except (ValueError, KeyError):
pass
return prev
def differences(old, new):
if not old:
return []
out = []
for k in ("verdict", "input_tokens", "id_format", "tool_id_format", "usage_fields"):
if old.get(k) != new.get(k):
out.append(f"{k}: {old.get(k)} -> {new.get(k)}")
if old.get("latency_ms") and new.get("latency_ms") and new["latency_ms"] > 3 * old["latency_ms"]:
out.append(f"latency_ms: {old['latency_ms']} -> {new['latency_ms']}")
return out
def notify(text):
token, chat = os.environ.get("TG_TOKEN"), os.environ.get("TG_CHAT")
if token and chat:
requests.post(f"https://api.telegram.org/bot{token}/sendMessage", timeout=30, data={"chat_id": chat, "text": text})
if __name__ == "__main__":
prev = last_runs()
with open(LOG, "a", encoding="utf-8") as f:
for m in MODELS:
r = check(m)
f.write(json.dumps(r, ensure_ascii=False) + "\n")
diff = differences(prev.get(m), r)
print(m, r["verdict"], r.get("input_tokens"), f"{r['latency_ms']} ms", "; ".join(diff))
if diff or r["verdict"] != "ok":
notify(f"relay_watch {m}: {r['verdict']}\n" + "\n".join(diff))Variáveis de ambiente: RELAY_BASE_URL (sem /v1), RELAY_API_KEY, MODELS (separados por vírgula), LOG_FILE e, opcionalmente, TG_TOKEN e TG_CHAT para o Telegram. Rode pelo cron, por exemplo a cada seis horas:
0 */6 * * * . /opt/relay-watch/env && python3 /opt/relay-watch/relay_watch.py >> /opt/relay-watch/cron.log 2>&1
Consulte o histórico com jq:
jq -r '[.ts, .model, .verdict, .input_tokens, .latency_ms] | @tsv' relay_watch.jsonl | tail -20
Como interpretar uma mudança
| O que mudou | Causa provável |
|---|---|
O veredito virou SIGNATURES NOT CHECKED | A assinatura editada não é mais recusada: a Anthropic não está processando as requisições |
O veredito virou thinking blocks broken | O relay começou a converter as requisições e perde as assinaturas |
Só input_tokens mudou | Um prompt de sistema foi adicionado ou alterado, ou a requisição vai para um modelo com outro tokenizador. Verifique antes se a Anthropic lançou uma nova versão do modelo |
Sumiram os IDs msg_ / toolu_ ou os campos de usage | Mudou o canal por trás do relay |
| Erros 402 ou 429 | Orçamento ou limites da chave, não troca de modelo |
| Só a latência subiu | Mais provável carga do que troca; observe se persiste |
O que nenhum teste externo consegue dizer
- Prova "o Claude da Anthropic", não "qual Claude". Um relay que manda em silêncio as requisições de Opus para o Sonnet, ambos na Anthropic, passa com uma assinatura genuína. O número de tokens de entrada também não ajuda aqui, porque modelos da mesma família podem compartilhar o tokenizador. Uma troca de nível só aparece comparando custo e latência com a API oficial em requisições idênticas, ou com um pequeno conjunto de tarefas difíceis rodadas de tempos em tempos.
- Mostra o momento do teste. Repita, e nos modelos que você realmente usa.
- Precisa do formato nativo da Anthropic. Endpoints no formato da OpenAI descartam as assinaturas de thinking, então o teste não roda neles.
- Passar significa "nenhum indício de troca", não uma prova. As auditorias acadêmicas chegam à mesma conclusão: só a atestação por hardware (TEE) provaria qual modelo está rodando, e nenhum canal de revenda oferece isso.
Nossos resultados
Testamos a APIVAI em 19 de outubro de 2026: o teste único com o Claude Haiku 4.5 pelo endpoint público e com o Claude Sonnet 4.6 no mesmo backend, e o monitor com o Claude Haiku 4.5.
- Primeira requisição: ID de mensagem
msg_011…, ID de ferramentatoolu_…,usagecomservice_tier,inference_geoe um objetocache_creationaninhado, e um bloco de thinking assinado. - Assinatura inalterada: 200. Assinatura editada: 400
Invalid signature in thinking block. - O monitor registrou o mesmo número de tokens de entrada (759) em execuções seguidas; quando editamos à mão o número registrado, a execução seguinte avisou da mudança.
- Ausentes: cabeçalhos
anthropic-ratelimit-*nas respostas, por isso não os oferecemos como sinal.
É tudo o que esses testes mostram, dentro dos limites acima.
FAQ
Posso rodar o teste num endpoint no formato da OpenAI?
Não. O formato da OpenAI não carrega assinaturas de thinking, então não há o que verificar. Aponte o script para o endereço nativo da Anthropic do relay (o que o Claude Code usa); se um relay só oferece o formato da OpenAI, este teste não consegue verificá-lo.
Com que frequência devo rodar?
A cada poucas horas basta na maioria dos casos: a cada seis horas com dois modelos são 24 requisições por dia. Rodar mais de uma vez por hora acrescenta pouco, porque a troca não acontece minuto a minuto. Verifique os modelos que você realmente paga.
Quanto custa uma verificação?
Três requisições curtas por modelo: uma pequena fração de centavo no Claude Haiku e cerca de um centavo no Claude Sonnet, aos preços do próprio relay.
Ele percebe se o Opus for trocado pelo Sonnet?
Não. Os dois são modelos da Anthropic, então a assinatura é genuína em ambos os casos, e eles podem compartilhar o tokenizador. Para pegar uma troca de nível, compare custo e latência com a API oficial em requisições idênticas, ou mantenha algumas tarefas difíceis que o modelo mais barato erra.
Se um relay passar, posso ficar tranquilo?
Passar significa que não há sinal de troca no momento do teste, nada além disso. Continue monitorando, acompanhe a fatura por requisição e prefira relays que mostrem, para cada requisição, tokens e custos que você consiga conferir.