Repository navigation
feat(backend): resolve virtual models in the proxy - #188
Conversation
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>
Revisão: PR #188 (modelos virtuais no proxy, PR 2 da ADR 0004)Escopo: diff CI no SHA revisado (check-runs do commit): os 19 checks estão verdes:
Critérios de risco alto: nenhum se aplica.
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 ADRPrecedência (seção 5): correta.
Cadeia (seção 4): correta.
M301: correto.
Observabilidade (seção 8): correta.
Parâmetros (seção 9): corretos.
Upstream: o modelo encaminhado é sempre 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:
O que o autor fez além do escopo pedidoAs duas extensões estão corretas.
Must-fixNenhum. Should-fixNenhum. Nits1. 2. 3. ResumoNão há blocker, should-fix nem critério de risco alto. O CI completo está verde neste SHA. O merge depende do Head revisado: 1c147a4 |
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>
Re-revisão: PR #188 (head
|
| 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.
recordFallbackFailurespassaprimaryRequestParamsquando existe e cai emmeta.request_paramsquando não existe. Ou seja, mantém o comportamento antigo nos caminhos em que o meta não traz o campo.recordFailedFallbacksusaf.requestParamsquando 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_defaultsó é preenchido junto comvirtual_model_unavailable.- As duas variantes do
detailestão corretas: com a opção ligada, o texto não sugere ligá-la de novo. docs/erros.mdfoi atualizado em PT-BR.- O e2e confere as duas variantes.
{detail}é o único placeholder novo e só há um chamador deformatThinkerError('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
* 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>
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 onmainat5ed7392.What changes
Precedence (
ProxyService.resolveRouting, ADR section 5):model, matched case-insensitively with no other normalization;resolveExplicitModelandresolveLazy;autogoes toresolveLazy, a real model ID to an explicit route, and an unknown name to M302.ResolveService.resolve()/resolveLazy()no longer take headers, soPOST /api/v1/routing/resolvestill applies no header tiers. Anautoheartbeat still goes straight toresolveForTier('simple')and skips the header tier step.Chain (
ResolveService.resolveVirtualModel, ADR section 4):buildVirtualModelChain.enrichRouteKeyLabelruns asnormalize, before deduplication.isRouteAvailableanswersisDefaultOverrideAvailable, which is now a required option (review note on feat(backend): virtual models entity, migration and admin API #186).isRouteAvailable.buildResolvedRouteChain: an available override, or the first route with credentials, comes first; the routes after it go throughisRouteAvailable. A Default that only hasauto_assigned_routeis still reachable.response_modeacross the whole chain. The response also carries the virtual'soutput_modality.debugfor each match and atwarnfor each dropped route.M301 "Virtual model has no available route":
error-codes.ts/THINKER_ERRORS;routing_reasonisvirtual_model_unavailable, classified inthinker-sharedasconfig/no_provider;docs/erros.md(PT-BR);Observability:
X-Thinker-Reason: virtual-model,X-Thinker-Virtual-Model: <name>andX-Thinker-Tier: default;X-Thinker-Fallback-*andX-Thinker-Fallback-Exhaustedwork inside the chain;agent_messages.virtual_model_id/virtual_model_nameare 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;Per-route params scope (ADR section 9):
ParamMergeContext.scopeKeyis replaced byscopeFor(route). Each forward carriesparamsRoute.modelParamsService.get/snapshotRequestParams, in the re-derivedrequest_paramswhen a fallback wins (looked up by the winning route), and in the exhausted-chain snapshot.virtual:<id>and borrowed Default routes usetier:default, so a primary that comes from the Default usestier:default.explicit_model_override.docs/dev/guia.mdrouting section updated. Changeset:.changeset/virtual-models-proxy.md.proxy.controller.tschanges are limited to thehandleProxyRequestrecording helpers.models()is not touched.Divergences from the ADR
Tests
resolve.service.virtual-models.spec.ts(new), which covers:continue_on_defaulton and off;auto_assigned_route;proxy.service.spec.ts.proxy-fallback.routes.spec.ts.resolve.servicenow go throughresolveHeaderTier, and a new test checks thatresolve()never reads header tiers.test/virtual-models-proxy.e2e-spec.ts(24 tests), which covers:continue_on_default, including a Default with onlyauto_assigned_routeand an empty Default;QA follow-up (
e3b97cd)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.Checks (local, after rebasing on
5ed7392, heade3b97cd)npm run lint(root)jest)tsconfig.test.json)nest build)jest)TZ=UTC npm run test:e2e:docker)Runtime behavior changes, so
qa-dockermust run before this PR leaves draft.Refs #176
🤖 Generated with Claude Code