Skip to content

feat(backend): resolve virtual models in the proxy - #188

Merged
patrickbrandao merged 3 commits into
mainfrom
feat/virtual-models-proxy
Oct 26, 2026
Merged

patrickbrandao merged 3 commits into
mainfrom
feat/virtual-models-proxy

Conversation

@patrickbrandao

@patrickbrandao patrickbrandao commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

PR 2 of the delivery plan in ADR 0004 (#176): the proxy now resolves virtual models. It builds on #186 (entity/API) and #187 (/v1/models), and is rebased on main at 5ed7392.

What changes

  • Precedence (ProxyService.resolveRouting, ADR section 5):

    1. an enabled virtual model whose name equals model, matched case-insensitively with no other normalization;
    2. a header tier. The header tier lookup is now one step shared by the auto and explicit paths: it moved out of resolveExplicitModel and resolveLazy;
    3. auto goes to resolveLazy, a real model ID to an explicit route, and an unknown name to M302.

    ResolveService.resolve()/resolveLazy() no longer take headers, so POST /api/v1/routing/resolve still applies no header tiers. An auto heartbeat still goes straight to resolveForTier('simple') and skips the header tier step.

  • Chain (ResolveService.resolveVirtualModel, ADR section 4):

    • Built with buildVirtualModelChain. enrichRouteKeyLabel runs as normalize, before deduplication. isRouteAvailable answers isDefaultOverrideAvailable, which is now a required option (review note on feat(backend): virtual models entity, migration and admin API #186).
    • The virtual's own routes go through isRouteAvailable.
    • The Default part follows buildResolvedRouteChain: an available override, or the first route with credentials, comes first; the routes after it go through isRouteAvailable. A Default that only has auto_assigned_route is still reachable.
    • Then the transport filter runs with the virtual's response_mode across the whole chain. The response also carries the virtual's output_modality.
    • No scorer, specificity or momentum. A server log line goes out at debug for each match and at warn for each dropped route.
  • M301 "Virtual model has no available route":

    • added to error-codes.ts/THINKER_ERRORS;
    • routing_reason is virtual_model_unavailable, classified in thinker-shared as config/no_provider;
    • documented in docs/erros.md (PT-BR);
    • returned with the option off, and with the option on when the Default is empty. It never falls through to other routing.
  • Observability:

    • response headers X-Thinker-Reason: virtual-model, X-Thinker-Virtual-Model: <name> and X-Thinker-Tier: default; X-Thinker-Fallback-* and X-Thinker-Fallback-Exhausted work inside the chain;
    • agent_messages.virtual_model_id/virtual_model_name are written on every row of the request: the pending attempt (stamped through the attempt start), cancelled rows, failed primary and fallbacks, successes, provider errors, Thinker stubs and M301. header_tier_* stays null;
    • the messages list and the message detail queries now project both columns, for the dashboard work in PR 4.
  • Per-route params scope (ADR section 9):

    • ParamMergeContext.scopeKey is replaced by scopeFor(route). Each forward carries paramsRoute.
    • The scope is per route in the primary modelParamsService.get/snapshotRequestParams, in the re-derived request_params when a fallback wins (looked up by the winning route), and in the exhausted-chain snapshot.
    • The virtual's own routes use virtual:<id> and borrowed Default routes use tier:default, so a primary that comes from the Default uses tier:default.
    • A virtual request is not explicit_model_override.
  • docs/dev/guia.md routing section updated. Changeset: .changeset/virtual-models-proxy.md.

proxy.controller.ts changes are limited to the handleProxyRequest recording helpers. models() is not touched.

Divergences from the ADR

  • None in behavior.
  • Interpretation: in the Default part, the "credentials until the primary is chosen" check applies to the Default's own head. That head is the first Default route left after deduplication, or the override when it is available. The same model on another key counts as a different route, as section 4 says.

Tests

  • Unit:
    • resolve.service.virtual-models.spec.ts (new), which covers:
      • matching, disabled rows, isolation and native ID shadowing;
      • availability, M301 and continue_on_default on and off;
      • a Default with only auto_assigned_route;
      • deduplication with and without a label;
      • the stream filter.
    • Proxy precedence, M301, per-route scopes (including a primary taken from the Default), fallback re-derivation and attempt stamping in proxy.service.spec.ts.
    • Per-hop scope in proxy-fallback.routes.spec.ts.
    • Headers and recording in the response handler, recorder and controller specs.
    • The header-tier specs of resolve.service now go through resolveHeaderTier, and a new test checks that resolve() never reads header tiers.
  • e2e: test/virtual-models-proxy.e2e-spec.ts (24 tests), which covers:
    • the three endpoints;
    • virtual > header tier > auto/explicit;
    • a disabled virtual releasing a native ID, and M302;
    • M301 on all three endpoints;
    • continue_on_default, including a Default with only auto_assigned_route and an empty Default;
    • runtime fallback, streaming fallback into the Default part, and an exhausted chain;
    • deduplication with and without a label;
    • per-route params scope, checked against the bodies sent upstream;
    • a heartbeat sent with a tier header;
    • the resolve endpoint without header tiers;
    • that the virtual name never reaches the upstream body.

QA follow-up (e3b97cd)

  • Params snapshot per row. Every row now records the params snapshot of its own attempt. Before this fix, in a fallback-success flow the failed primary row carried the winner's snapshot, and failed hops carried the request's. Now the proxy computes the failed primary's snapshot (RoutingMeta.primaryRequestParams) and each failed hop's (FailedFallback.requestParams) under the scope of that attempt's route. Covered by unit tests and by e2e tests for buffered, streaming and exhausted chains.
  • M301 text. M301 now tells the two cases apart: with "Continue on Default" off, it suggests turning the option on; with it already on, it says the Default tier also has no available route.

Checks (local, after rebasing on 5ed7392, head e3b97cd)

Check Result
npm run lint (root) 0 errors (382 warnings, pre-existing)
shared build + typecheck ok
shared unit (jest) 22 suites, 557 tests passed
backend typecheck (tsconfig.test.json) ok
backend build (nest build) ok
backend unit (jest) 493 suites, 9419 tests passed
backend e2e (TZ=UTC npm run test:e2e:docker) 78 suites, 706 tests passed

Runtime behavior changes, so qa-docker must run before this PR leaves draft.

Refs #176

🤖 Generated with Claude Code

patrickbrandao and others added 2 commits October 26, 2026 00:19
Second PR of the ADR 0004 delivery plan (#176).

- Precedence in ProxyService.resolveRouting: an enabled virtual model
  whose name equals `model` (case-insensitive) first, then header tiers
  as a step shared by the auto and explicit paths, then auto/explicit.
  resolve()/resolveLazy() no longer read headers, so
  POST /api/v1/routing/resolve still applies no header tiers, and auto
  heartbeats still go straight to the simple tier.
- ResolveService.resolveVirtualModel builds the chain with
  buildVirtualModelChain (enrichRouteKeyLabel before dedup,
  isRouteAvailable for the Default override, now a required callback),
  filters availability like header tiers / buildResolvedRouteChain,
  applies the virtual's response mode, and skips the scorer.
- M301 "Virtual model has no available route": catalog, routing_reason
  virtual_model_unavailable, shared classification, docs/erros.md.
- X-Thinker-Reason: virtual-model, X-Thinker-Virtual-Model and
  X-Thinker-Tier: default; agent_messages.virtual_model_id/name on every
  row of the request (pending, attempts, success, errors, M301), also
  exposed by the messages and message-detail analytics queries.
- Per-route params scope: ParamMergeContext.scopeFor(route) replaces the
  single scopeKey, covering the primary snapshot and the fallback
  re-derivation; virtual routes use virtual:<id>, borrowed Default
  routes tier:default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@patrickbrandao

Copy link
Copy Markdown
Owner Author

Revisão: PR #188 (modelos virtuais no proxy, PR 2 da ADR 0004)

Escopo: diff origin/main...1c147a4fe7c92c23a619bdc11e6a422eaef6fb31, com merge-base 5ed7392, que inclui o #187. Li o código ao redor no próprio SHA: proxy.service.ts, proxy-fallback.service.ts, resolve.service.ts, proxy-friendly-response.ts, provider-key.service.ts, routing.tools.ts e model-route.ts.

CI no SHA revisado (check-runs do commit): os 19 checks estão verdes:

  • lint, typecheck e build;
  • unit de backend, shared, frontend e cli;
  • Backend e2e PG16 (UTC e America/Sao_Paulo), install.sh end-to-end e Compose smoke;
  • Docker amd64 e arm64;
  • CodeQL ("No new alerts"), CodeQL (actions), changeset, scripts e dependency-review.

qa-docker: o PR muda o hot path e está em draft. O qa-docker contra a imagem local, que roda em paralelo, é condição para sair de draft e para o merge.

Critérios de risco alto: nenhum se aplica.

  • Não mexe em auth, cripto ou chaves.
  • Não tem migração.
  • Não enfraquece o isolamento: a busca do virtual filtra agent_id e tenant_id, e as escritas nas linhas seguem o contexto já resolvido.

A mudança de tratamento de erro no hot path (M301, e o header tier deslocado) tem testes unitários e e2e. Por isso não é blocker.

Conferência contra a ADR

Precedência (seção 5): correta.

  • resolveRouting aplica nesta ordem: virtual (só fora de auto), header tier (headers && !isHeartbeat), depois auto/explícito/M302.
  • O header tier saiu de resolveExplicitModel e de resolveLazy.
  • Procurei todos os chamadores no SHA e em main. Em main, só proxy.service.ts chamava resolveLazy com cabeçalhos. resolve() (via resolve.controller.ts) nunca passou cabeçalhos.
  • O MCP thinker_routing_test faz fetch para /v1/* (routing.tools.ts:352-375) e a CLI usa a API, então os dois passam pelo proxy e herdam o passo comum.
  • Nenhum caminho perdeu o header tier:
    • explícito com tier continua com tier;
    • auto com tier passa a aplicar o tier antes de resolveLazy, como antes, só que em outro lugar;
    • heartbeat continua indo para resolveForTier('simple') sem tier;
    • /routing/resolve continua sem tier.
  • O e2e cobre auto e openai/gpt-4.1 com tier, heartbeat com tier e o endpoint de resolve.

Cadeia (seção 4): correta.

  • isDefaultOverrideAvailable virou obrigatório, e o callback usado é isRouteAvailable. normalize é enrichRouteKeyLabel, aplicado antes da deduplicação.
  • As rotas do virtual passam por isRouteAvailable.
  • No Default, a cabeça é o override disponível ou a primeira rota com hasRouteCredentials, e as rotas seguintes passam por isRouteAvailable. Isso segue buildResolvedRouteChain.
  • A leitura do autor (quando o override é deduplicado, a cabeça do Default é a primeira rota que sobra) é aceitável. Ela é no máximo tão permissiva quanto o resolvedor atual, que não filtra os fallbacks.
  • Depois vem o filtro de transporte com o response_mode do virtual. O output_modality também é o do virtual.
  • Não passa por scorer, especificidade nem momentum. recordTierIfScoring ignora default.

M301: correto.

  • Presente em THINKER_ERRORS, THINKER_CODE_TO_REASON (virtual_model_unavailable), THINKER_BLOCKED_REQUEST_REASONS, na taxonomia e em docs/erros.md (PT-BR).
  • A classificação config/no_provider é a mesma do M101 (error-taxonomy.ts:169). Está certa: quem resolve é o operador, consertando a cadeia, e não o cliente. Um request/not_found como o M302 culparia o cliente por um nome que existe e foi escolhido de propósito.

Observabilidade (seção 8): correta.

  • X-Thinker-Reason: virtual-model, X-Thinker-Virtual-Model e X-Thinker-Tier: default.
  • virtual_model_* é gravado em todos os writers: pendente (pelo wrapper de startProviderAttempt), cancelado, falha do principal e dos fallbacks, sucesso, erro do provedor, stubs e M301.
  • header_tier_* fica nulo, porque resolveVirtualModel não os define. O e2e confere header_tier_id: null.

Parâmetros (seção 9): corretos.

  • scopeFor(route) é usado no principal efetivo (modelParamsService.get e snapshotRequestParams), por hop em tryFallbacks (paramsRoute), na re-derivação após fallback (fallbackRoutes[success.fallbackIndex], alinhado com fallbackModels) e no snapshot de cadeia esgotada.
  • A busca é por identidade e depois por routeEquals, que compara keyLabel. Um virtual não é explicit_model_override.
  • O e2e confere os corpos enviados ao upstream, inclusive com um principal que veio do Default.

Upstream: o modelo encaminhado é sempre route.model, e o e2e confere que o nome do virtual não chega ao corpo enviado. O nome volta só para o cliente, no model da resposta, que já ecoa body.model como em auto.

Streaming: o aquecimento de stream e o fallback antes do primeiro chunk continuam como estavam. O e2e cobre o fallback em stream para dentro da parte do Default.

Desempenho:

  • Com cache quente, o passo 1 é VirtualModelService.list (cache de 2 min) mais uma busca em memória, para todo pedido não-auto. Num pedido explícito sem virtual, o custo extra é desprezível.
  • Num pedido para um virtual, o custo é até 12 × (enrichRouteKeyLabel + isRouteAvailable), todos sobre caches. Ver o nit 1.

O que o autor fez além do escopo pedido

As duas extensões estão corretas.

  1. virtual_model_* nas APIs de analytics (messages-query, query-helpers, message-details): são só colunas acrescentadas às projeções que já existem, sob o mesmo escopo de tenant e agente das consultas atuais, no mesmo padrão de header_tier_*. A mudança é aditiva para os clientes da API e já adianta o PR 4. O e2e completo está verde.
  2. X-Thinker-Virtual-Model no M301: está coerente com a seção 8 ("cabeçalhos de resposta") e com a gravação do M301 com o virtual. Ajuda a depurar qual nome caiu no M301, e docs/erros.md documenta. O nome tem charset seguro para cabeçalho (seção 3), então não há risco de injeção.

Must-fix

Nenhum.

Should-fix

Nenhum.

Nits

1. resolve.service.ts (resolveVirtualModel): normalize e as checagens de disponibilidade rodam em série, rota a rota. Com até 12 rotas, quando a descoberta está fria, cada isRouteAvailable pode cair no fallback providerRepo.find (provider-key.service.ts:435-446). Isso dá até 12 consultas no pedido. O header tier para na primeira rota disponível. Uma opção é resolver as checagens do virtual com Promise.all, já que a ordem é preservada pelo array, ou memoizar isRouteAvailable por (provider, authType, model) dentro do pedido. Não bloqueia: com descoberta quente, tudo vem de cache.

2. proxy.service.ts (paramsScopeLookup): uma rota fora da cadeia cai em scopes[0]. Hoje isso não acontece com virtuais, porque elas sempre usam rotas estruturadas. Um logger.debug nesse ramo facilitaria achar uma regressão futura em que um hop sem rota herde o escopo virtual:<id>.

3. resolve.service.ts:122-128: o JSDoc novo de resolveLazy ficou separado do método por uma linha em branco. É só cosmético.

Resumo

Não há blocker, should-fix nem critério de risco alto. O CI completo está verde neste SHA. O merge depende do qa-docker, que roda em paralelo, e da saída de draft.

Head revisado: 1c147a4
Veredito: aprovado

QA findings on #188:

- Each agent_messages row now records the params of its own attempt.
  In a fallback-success flow the failed primary row used the winner's
  snapshot, and failed hops inherited the request's. The proxy now
  snapshots the primary (RoutingMeta.primaryRequestParams) and every
  failed hop (FailedFallback.requestParams) under the scope of its own
  route; the recorder prefers them. Covers streaming and exhausted
  chains too.
- M301 tells the two cases apart: with "Continue on Default" off it
  suggests turning it on; with it on it says the Default tier has no
  available route either (docs/erros.md updated).
- resolveLazy's JSDoc sits directly on the method again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@patrickbrandao

Copy link
Copy Markdown
Owner Author

Re-revisão: PR #188 (head e3b97cd)

Escopo. Delta 1c147a4..e3b97cd (um commit), mais o diff completo origin/main...e3b97cd com merge-base 5ed7392, que não mudou. Fora do delta, nada mudou desde a revisão anterior.

CI no SHA revisado (check-runs do commit). Os 19 checks estão verdes:

  • lint, typecheck e build;
  • unit de backend, shared, frontend e cli;
  • Backend e2e PG16 (UTC e America/Sao_Paulo), install.sh end-to-end e Compose smoke;
  • Docker build amd64 e arm64;
  • CodeQL ("No new alerts"), CodeQL (actions), changeset, scripts e dependency-review.

O qa-docker precisa passar neste SHA antes do merge e da saída de draft, já que o commit corrige achados dele.

1. request_params por tentativa

O que mudou em tryFallbackChain:

  • o snapshot do principal agora é sempre o dele (attemptRequestParams com scopeFor(resolved.route)), e vai em RoutingMeta.primaryRequestParams;
  • cada hop que falhou recebe o próprio snapshot, no escopo da própria rota (FailedFallback.requestParams);
  • o vencedor continua em request_params;
  • a cadeia esgotada reaproveita o snapshot do principal.

Caminhos sem virtual (tier, header tier, especificidade). scopeFor devolve um escopo único, então a única diferença gravada está nas linhas de falha:

Linha Antes Agora
Principal que falhou, com um fallback vencendo snapshot do vencedor snapshot do próprio principal
Hop que falhou snapshot do vencedor (sucesso) ou do principal (esgotada) snapshot do próprio modelo

As duas mudanças corrigem o mesmo defeito que o autor apontou: um modelo diferente é outra consulta de params. A linha de sucesso e a linha da cadeia esgotada gravam o mesmo valor que antes. O snapshot da cadeia esgotada era recalculado com a mesma consulta e agora é reaproveitado. Não vi outra mudança de comportamento.

Gravação.

  • recordFallbackFailures passa primaryRequestParams quando existe e cai em meta.request_params quando não existe. Ou seja, mantém o comportamento antigo nos caminhos em que o meta não traz o campo.
  • recordFailedFallbacks usa f.requestParams quando ele não é undefined.

Cobertura.

  • Recorder e response handler: testes unitários.
  • proxy.service: "snapshots each attempt under the scope of its own route".
  • e2e com stream: false/true (o fallback acontece antes do primeiro byte), com virtual, fallback próprio e Default.
  • e2e de cadeia esgotada (503) com as duas linhas, cada uma com o próprio valor.

Custo. São consultas extras de modelParamsService.get/getSpecs por tentativa, depois que a cadeia termina. Ambas vêm de cache em memória (Map por agente e specs estáticas), então o impacto antes do primeiro byte do fallback vencedor é desprezível.

2. Texto do M301

  • virtual_model_continues_on_default só é preenchido junto com virtual_model_unavailable.
  • As duas variantes do detail estão corretas: com a opção ligada, o texto não sugere ligá-la de novo.
  • docs/erros.md foi atualizado em PT-BR.
  • O e2e confere as duas variantes. {detail} é o único placeholder novo e só há um chamador de formatThinkerError('M301').

Nit 3 da revisão anterior

Resolvido: a linha em branco foi removida.

Must-fix

Nenhum.

Should-fix

Nenhum.

Nit

proxy.service.ts (scopedFailures): um hop sem authType grava requestParams: null. Como null !== undefined, o recorder não cai no snapshot do pedido, e a linha fica sem request_params. Para o principal sem authType, o código grava um snapshot só do corpo (snapshotRequestParams com modelParams: null). Para ser coerente, o hop poderia gravar o mesmo. O caso é raro: falhas estruturadas e de credencial sempre têm authType.

Veredito

Não há blocker, should-fix nem critério de risco alto, e o CI completo está verde neste SHA. O merge depende do qa-docker neste head e da saída de draft.

Head revisado: e3b97cd
Veredito: aprovado

@patrickbrandao
patrickbrandao marked this pull request as ready for review October 26, 2026 03:47
@patrickbrandao
patrickbrandao merged commit 257ebc4 into main Oct 26, 2026
19 checks passed
@patrickbrandao
patrickbrandao deleted the feat/virtual-models-proxy branch October 26, 2026 03:47
patrickbrandao added a commit that referenced this pull request Oct 26, 2026
* chore(release): version 0.1.2

Bump the thinker version package to 0.1.2 and consume the pending
virtual model changesets (ADR 0004, #176: #186, #187, #188, #190, #191,
#198, #199, #200) into the changelog, ordered by part. Finalize the PT-BR
release notes in docs/releases/0.1.2.md: drop the draft marker and add
#199 (routable and context_length ignore disconnected providers) and
#200 (409 guard for providers used by virtual models, keyLabel pins
follow key rename and deletion).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(release): address review nits on the 0.1.2 notes and changelog

- Release notes: qualify the upgrade note; context_length on auto, the
  per-attempt request_params fix and the optional
  RouteModelParamsView.tier apply even without virtual models.
- Release notes: drop the stray blank line in the "Corrigido" list.
- CHANGELOG: link the PT-BR release notes from the 0.1.2 entry, as the
  0.1.1 entry does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant