Skip to content

feat(mcp,cli): virtual models in the MCP tools and the thinker CLI - #190

Merged
patrickbrandao merged 4 commits into
mainfrom
feat/virtual-models-mcp-cli
Oct 26, 2026
Merged

patrickbrandao merged 4 commits into
mainfrom
feat/virtual-models-mcp-cli

Conversation

@patrickbrandao

Copy link
Copy Markdown
Owner

PR 5 of the ADR 0004 delivery plan (#176): virtual models in the MCP tools and the thinker CLI.

What changes

MCP (packages/backend/src/mcp/tools/virtual-models.tools.ts, routing.tools.ts)

  • New tools thinker_routing_virtual_list, thinker_routing_virtual_create (name, model with optional provider/auth_type/key_label, fallbacks, continue_on_default, response_mode), thinker_routing_virtual_update and thinker_routing_virtual_delete. Update and delete find the virtual model by name (ignoring case) or by id.
  • _update changes only the fields it receives: model replaces the primary, fallbacks replaces the list ([] clears it), clear_model removes the primary and its fallbacks, plus name, continue_on_default, response_mode and enabled. Route writes go first, so a rejected route leaves the other fields unchanged.
  • The write tools are registered only with mcp:write, like the existing ones.
  • thinker_routing_status lists the virtual models (id, name, enabled, route, fallbacks, continue_on_default, response_mode).
  • thinker_routing_test accepts a virtual model name in model. It reports routing_reason and virtual_model, read from X-Thinker-Reason and X-Thinker-Virtual-Model.
  • thinker_routing_params_get/_set gain a virtual field, exclusive with tier.

Model params

  • RouteModelParamsService takes either a tier name or { virtual: <name> }. It resolves the virtual model by name, ignoring case, to the virtual:<id> scope.
  • Only the virtual model's own chain can be addressed. Routes borrowed from the Default tier keep tier:default, as in ADR section 9.
  • New endpoint GET|PATCH /api/v1/routing/:agent/virtual-model-params?virtual=<name>&model=<id>, next to tiers/:tier/model-params. The name goes in the query string because it may contain /.
  • The scope-keyed PUT|DELETE /model-params endpoint already accepted scope: "virtual:<id>". An e2e test now covers it.

CLI (packages/cli/src/commands/routing.ts)

  • thinker routing virtual list|create|update|delete|enable|disable:
    • create takes --name, --model (with optional --provider, --auth-type, --key-label), repeatable --fallback, --continue-on-default and --response-mode;
    • update takes --name, --model, --fallback, --clear-fallbacks, --clear-model, --continue-on-default / --no-continue-on-default and --response-mode;
    • delete requires --yes, like routing custom delete.
  • routing status now includes virtual_models.
  • routing test --model <virtual> shows virtual_model and routing_reason.
  • routing params get|set --virtual <name> is exclusive with --tier.

Docs

  • The skill (packages/cli/skill/SKILL.md) and the regenerated src/skill-content.gen.ts.
  • The MCP operating guide.
  • docs/dev/guia.md, routing section.
  • PT-BR user docs are left for PR 7.

The proxy resolution, /v1/models and the frontend are unchanged.

Divergences from the ADR

  • Extra flags. Beyond the flags the ADR lists, create/update also accept --provider, --auth-type, --key-label and --response-mode. update also accepts --name, --clear-fallbacks, --clear-model and --no-continue-on-default, so the last two can be undone. The MCP tools have the matching fields.
  • Fallbacks need a primary. The MCP tools and the CLI reject fallbacks when the virtual model has no primary. Clearing the primary already drops its fallbacks everywhere. The REST service is unchanged.
  • New endpoint. The ADR only says the params REST endpoint "accepts the virtual:<id> scope". The scope-keyed endpoint already did. Name-based access needed a new endpoint, virtual-model-params, because the tier-addressed path cannot carry a virtual model name.

Checks (local; CI pending #38)

  • npm run lint: 0 errors. The 383 warnings were there before this PR; the changed files have none.
  • Typecheck: shared, backend (tsconfig.test.json) and cli all clean.
  • Build: shared, backend (nest build) and cli (gen + tsc) all OK.
  • Unit tests:
    • shared: 22 suites, 557 tests passed;
    • backend: 493 suites, 9437 tests passed;
    • cli: 14 suites, 322 tests passed.
  • Backend e2e (TZ=UTC npm run test:e2e:docker, disposable postgres:16-alpine): 79 suites, 715 tests passed. This includes the new test/virtual-models-mcp.e2e-spec.ts.
  • npm run check:changesets: OK.

QA

qa-docker should run: the PR changes runtime behavior of the MCP server, the CLI and a new REST endpoint.

🤖 Generated with Claude Code

Fifth PR of the ADR 0004 delivery plan (#176).

- MCP: thinker_routing_virtual_list, _create (name, model with optional
  provider/auth_type/key_label pins, fallbacks, continue_on_default,
  response_mode), _update (rename, primary, fallbacks with [] to clear,
  clear_model, continue_on_default, response_mode, enabled; route writes
  first) and _delete, found by name ignoring case or by id. Write tools
  follow the mcp:write rule of the existing ones.
- thinker_routing_status lists the virtual models; thinker_routing_test
  accepts a virtual model name in model and reports routing_reason and
  virtual_model from the X-Thinker-* response headers.
- Model params: RouteModelParamsService takes a tier name or
  { virtual: <name> } and resolves the virtual model by name (case-
  insensitive) to the virtual:<id> scope; only its own chain is
  addressable. thinker_routing_params_get/_set gain a virtual field,
  exclusive with tier. New REST endpoint
  GET|PATCH /api/v1/routing/:agent/virtual-model-params?virtual=&model=;
  the scope-keyed /model-params endpoint already accepts virtual:<id>.
- CLI: thinker routing virtual list|create|update|delete|enable|disable
  (--model, repeatable --fallback, --continue-on-default, delete needs
  --yes); routing status includes virtual models; routing test --model
  <virtual> shows the virtual model and reason; routing params get|set
  --virtual, exclusive with --tier.
- Skill (SKILL.md + regenerated skill-content.gen.ts), MCP operating
  guide and docs/dev/guia.md updated.
- Tests: MCP tool and params service/controller unit tests, CLI unit
  tests, and an e2e spec driving the routing tools against the real
  services and database (validation, 409, virtual:<id> params, REST
  readback, update, delete cleanup, tenant isolation).

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

Copy link
Copy Markdown
Owner Author

Revisão: PR #190 (modelos virtuais no MCP, na CLI e no endpoint de parâmetros por nome; PR 5 da ADR 0004)

Escopo

  • Diff origin/main...cbafbf6112cfb1f63a889a8e960932ae624a3ec2, com merge-base 257ebc4 (o feat(backend): resolve virtual models in the proxy #188 já em main).
  • Código ao redor lido no próprio SHA: routing-status.ts, route-model-params.*, virtual-model.service.ts, mcp-auth e cli/src/args.

Histórico: está limpo. Há um único commit (cbafbf6) sobre 257ebc4, que é o topo atual de main. Não sobrou nada do push inicial.

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 (este inclui o drift guard skill-content.spec.ts, que confirma que a skill foi regenerada);
  • Backend e2e PG16 (UTC e America/Sao_Paulo), install.sh end-to-end e Compose smoke;
  • Docker build amd64/arm64;
  • CodeQL ("No new alerts"), CodeQL (actions), changeset, scripts e dependency-review.

qa-docker: o PR acrescenta ferramentas MCP e um endpoint REST, então muda comportamento de runtime e ainda está em draft. Pela regra do repositório, o qa-docker contra a imagem local é condição para sair de draft e para o merge.

Critérios de risco alto: nenhum se aplica. O PR não mexe em auth, cripto, credenciais nem migração, e não enfraquece o isolamento.

Conferido contra as seções 9 e 12 da ADR

Ferramentas

  • thinker_routing_virtual_list/_create/_update/_delete existem.
  • routing_status lista os virtuais.
  • routing_test aceita um virtual e devolve routing_reason e virtual_model, lidos dos cabeçalhos X-Thinker-*.
  • params_get/_set têm o seletor virtual, exclusivo com tier (paramsTarget recusa os dois juntos).

Permissões MCP

  • _list, params_get e status ficam em mcp:read.
  • _create/_update/_delete só são registradas com MCP_WRITE_SCOPE, e params_set continua sob a mesma regra.
  • Os testes de ferramenta confirmam que um token só com mcp:read não vê as ferramentas de escrita.

Isolamento

  • Todas as ferramentas e os dois endpoints novos resolvem o agente com resolveAgent.resolve(operator.tenantId | ctx.tenantId, agentName), e só depois buscam o virtual com VirtualModelService.list(agent.id).
  • As escritas passam pelo serviço do feat(backend): virtual models entity, migration and admin API #186, que filtra por agent_id.
  • O endpoint virtual-model-params só lê e grava virtual:<id> de um virtual desse agente.
  • Há e2e entre tenants.

CLI

  • delete exige --yes (requireYes).
  • --tier e --virtual são exclusivos (paramsRequest), e --continue-on-default e --no-continue-on-default também.
  • routing status inclui os virtuais.

Escopo dos parâmetros: findVirtualModel usa modelParamsScopeForVirtualModel(id). Um virtual e um header tier com o mesmo nome ficam separados, e o e2e cobre esse caso.

Divergências declaradas

  1. Flags e campos extras (--provider, --auth-type, --key-label, --response-mode, --name, --clear-*, --no-continue-on-default, enabled, clear_model). Aceitável. Eles espelham campos que a API do feat(backend): virtual models entity, migration and admin API #186 já aceita, com as mesmas validações no servidor (incluindo o 400 de keyLabel). Sem eles, o MCP e a CLI não teriam paridade com o dashboard.
  2. Fallback sem principal é recusado. Aceitável. É coerente com clearOverride, que derruba os fallbacks junto. A API administrativa ainda aceita criar um virtual só com fallbacks, o que é uma pequena assimetria (nit 2).
  3. Nome do virtual na query string. Aceitável. Nomes podem ter /, como os IDs de modelo, que já vão em ?model=. O DTO limita a 64 caracteres.
  4. thinker_routing_status diz "not routable" para um agente que só tem virtuais. Should-fix 1, detalhado abaixo.
  5. Parâmetros por virtual só cobrem a cadeia própria; as rotas emprestadas do Default usam tier:default. Correto. É o que a seção 9 manda, e as mensagens da ferramenta e do erro explicam isso.

Should-fix

1. mcp/tools/routing.tools.ts:126-138 (thinker_routing_status): o status dá um falso negativo quando o agente só tem virtuais.

  • resolveRoutingStatus responde enabled: false, reason: 'no_routable_models' sempre que não há tier roteável.
  • A descrição da ferramenta é "Report whether a harness can route yet, and why not". Um agente com provedor ativo e só virtuais roteia todo pedido com model igual ao nome de um virtual ativo, mas recebe "não roteia".
  • Cenário concreto: um agente LLM consulta o status antes de usar o harness, lê enabled: false e conclui que a configuração está incompleta. Aí ele recria o Default ou desiste, mesmo com a lista virtual_models logo ao lado.
  • Correção mínima, sem mudar o RoutingStatus compartilhado com o dashboard (provider.controller.ts:81): acrescentar ao resultado do MCP (e ao routing status da CLI) um campo como routes_by_virtual_model: boolean (há virtual ativo com principal ou fallback) e ajustar a descrição para "enabled refers to auto". A alternativa é trocar o reason por algo como auto_not_routable quando houver virtual ativo.
  • Pode ser corrigido neste PR ou virar issue com o link registrado no PR antes do merge.

Nits

1. A atualização em vários passos não é atômica (virtual-models.tools.ts, thinker_routing_virtual_update, e routingVirtual.update na CLI). Os comentários dizem "a rejected route leaves the rest untouched" e "a rejected route changes nothing". Mas se o PUT /override passar e o PUT /fallbacks falhar, ou se o PUT /:id der 409 no rename, o principal já foi trocado. Vale ajustar o comentário para "route writes go first, so a rejected route leaves name/flags untouched", ou validar tudo antes de escrever.

2. Assimetria com a API administrativa: POST /virtual-models aceita um virtual só com fallbacks, e o MCP e a CLI não aceitam. Se a regra for "fallback exige principal", vale aplicá-la também no serviço, para as três superfícies concordarem.

3. RouteModelParamsView.tier virou opcional. A mudança é compatível para tier, que continua presente quando o alvo é tier, mas é uma mudança de forma do contrato da ferramenta e do endpoint. Vale citá-la no changeset ou no guia.

Resumo

Não há blocker nem critério de risco alto. Fica um should-fix, que precisa ser corrigido ou virar issue com o link registrado no PR antes do merge. O CI completo está verde neste SHA. O merge também depende do qa-docker e da saída de draft.

Head revisado: cbafbf6
Veredito: aprovado

patrickbrandao and others added 2 commits October 26, 2026 08:47
Addresses the review of #190.

- thinker_routing_status: enabled/reason stay the shared auto verdict
  (status_for: "auto"; RoutingStatus and the dashboard endpoint are
  unchanged). New routes_by_virtual_model and active_virtual_models
  (enabled, with a route of their own or continuing on a routable
  Default). When only virtual models can route, reason becomes
  auto_not_routable and auto_reason keeps the auto cause. No active
  provider still reports no_provider with no active virtual models.
- thinker routing status: the same two fields.
- Virtual model update (MCP and CLI): the writes are separate calls, not
  one transaction. A taken name (and, in MCP, an invalid one) is refused
  before any write, and a failure after an earlier write now lists the
  steps already applied. Comments and the tool description say so.
- Changeset notes that RouteModelParamsView.tier is now optional; skill,
  regenerated skill content and docs/dev/guia.md updated.
- Unit tests for both status cases and the partial update; e2e covers
  the only-virtual and auto-routable status cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by qa-docker on #190: `routing params --virtual v --model
fake2/gpt-4o` answered 400 because the chain stores the custom
provider's internal id (custom:<id>/gpt-4o), while virtual create and
update accept the public one.

- RouteModelParamsService matches `model` against each route's stored
  id, its public id (openAiModelId of the agent's catalog entry, e.g.
  <alias>/<model>), and its native id with or without the provider
  prefix. All names are matched in one pass, so a native id two
  connections of the chain share is reported as ambiguous, naming the
  public ids to use. Applies to tiers and virtual models alike.
- The view adds public_model and public_models next to route and models;
  the not-routed error shows a custom route's public id.
- Callers (REST controller, MCP params tools) pass the agent's tenant so
  the catalog can be read; a catalog read failure falls back to
  route-derived names.
- Unit tests for public/native/stored ids, ambiguity, tiers and the
  missing-catalog paths; e2e with an aliased custom provider in a
  virtual chain. Skill, changeset and docs/dev/guia.md updated.

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

Copy link
Copy Markdown
Owner Author

Re-revisão: PR #190 (head 2501ecc)

Escopo. Delta cbafbf6..2501ecc (commits 24d5a98 e 2501ecc), mais o diff completo origin/main...2501ecc. O merge-base continua 257ebc4.

Mergeabilidade. main andou para 7f4a90c (#191). A branch não contém o #191, mas git merge-tree não acusa conflito e o GitHub marca o PR como MERGEABLE. Os dois PRs não tocam os mesmos arquivos.

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 (este com o drift guard da skill);
  • Backend e2e PG16 (UTC e America/Sao_Paulo), install.sh end-to-end e Compose smoke;
  • Docker amd64/arm64;
  • CodeQL ("No new alerts"), changeset, scripts e dependency-review.

Os checks de pull_request rodam sobre o merge com a base atual, então incluem o #191. O e2e completo, que o autor não rodou localmente, passou no CI.

qa-docker. Precisa passar neste head antes de sair de draft e do merge, porque o 2501ecc corrige um achado dele.

Achados anteriores

Should-fix 1 (status): resolvido.

  • O MCP agora devolve status_for: "auto", routes_by_virtual_model e active_virtual_models. Quando só os virtuais roteiam, devolve reason: "auto_not_routable" com auto_reason. Com no_provider, a lista fica vazia.
  • O RoutingStatus do dashboard (provider.controller.ts) não mudou.
  • A CLI expõe os mesmos dois campos.
  • Ver o nit 1 sobre a definição.

Nit 1 (falha parcial): resolvido.

  • O MCP e a CLI agora checam o formato e a unicidade do nome antes de qualquer escrita.
  • Uma falha depois de passos já aplicados diz quais passos foram aplicados.
  • Os comentários deixaram de prometer atomicidade.

Nit 3 (changeset): resolvido.

2501ecc: casamento por ID público e nativo

O que conferi e está correto:

  • Dashboard não é afetado. Ele usa os endpoints por scope (/model-params, model-param-specs/*, ver frontend/src/services/api/model-params.ts), não tiers/:tier/model-params nem virtual-model-params. Quem chama o seletor por nome são só o MCP e a CLI.
  • Gravação. Continua com o ID armazenado da rota (paramsModel), então a chave das linhas de agent_model_params não muda.
  • Falha do catálogo. O .catch(() => []) faz o ID público cair no derivado da rota. Para um custom provider com alias, o nome do alias deixa de casar e a resposta é um 400 "not routed". Ou seja, recusa, nunca grava na rota errada. É seguro.
  • tenantId opcional. Todos os chamadores passam agent.tenant_id do agente já resolvido no tenant do chamador (resolveAgent.resolve(ctx.tenantId | operator.tenantId, …)), e getModelsForAgent é chaveado pelo agentId desse agente. Não há caminho para ler o catálogo de outro tenant.

Should-fix 1: um ID que antes casava de forma única agora pode virar ambíguo

route-model-params.service.ts, nas funções resolve e routeModelNames.

Antes, o casamento era pelo ID armazenado (r.model === model). Agora, cada rota aceita um conjunto de nomes (armazenado, público, nativo e <provider>/<nativo>), e todos têm o mesmo peso.

Cenário de falha. Considere um tier com principal direto na OpenAI e fallback pela OpenRouter, uma configuração comum:

  • rota A: { provider: "openai", authType: "api_key", model: "gpt-4o" }, com nomes gpt-4o e openai/gpt-4o;
  • rota B: { provider: "openrouter", authType: "api_key", model: "openai/gpt-4o" }, com nomes openai/gpt-4o e openrouter/openai/gpt-4o.

thinker routing params set <agent> --model openai/gpt-4o … (ou thinker_routing_params_set) casava só com B em main e em cbafbf6. Agora casa com A e B. São duas conexões, então a resposta é 400. A mensagem sugere "name it by its public id (openai/gpt-4o, openrouter/openai/gpt-4o)", e um dos IDs sugeridos é justamente o que acabou de ser recusado. Quem segue a dica fica preso no mesmo erro. Isso quebra uma chamada da CLI e do MCP que funcionava.

Correção sugerida: casar em camadas e parar na primeira que tiver resultado:

  1. ID armazenado (o comportamento de antes);
  2. ID público (openAiModelId);
  3. nomes derivados (nativo e <provider>/<nativo>).

A ambiguidade só vale dentro da camada vencedora. A lista de sugestões deve trazer só nomes que casam com uma única rota.

Falta um teste com esse caso (rota direta mais rota OpenRouter do mesmo modelo). O teste existente "treats a native id two connections share as ambiguous, and the public ids as exact" não cobre um ID público que é o ID armazenado de outra rota.

Nits

1. routes_by_virtual_model tem definições diferentes no MCP e na CLI.

  • MCP (routableVirtualModelNames): continue_on_default conta se status.enabled, que vem de hasRoutableTier, ou seja, de qualquer tier com override ou rota auto-atribuída.
  • CLI: conta só se o slot default tem override ou rota auto-atribuída.
  • Proxy: só acrescenta a cadeia do slot default (seção 4).

Por isso o MCP superestima num caso específico: um virtual sem rota própria, só com continue_on_default, numa instalação onde o default está vazio mas outro slot (simple etc.) tem rota. O MCP diz que roteia, a CLI diz que não, e o proxy responde M301.

Nenhum dos dois considera um default que só tem fallbacks, que buildResolvedRouteChain aceita.

Sugestão: usar nos dois a mesma regra, "o slot default tem override, rota auto-atribuída ou fallback" (no MCP, a partir de getTiers).

2. routeModelNames para custom provider. Para um custom provider, o conjunto não inclui o nome sem prefixo quando a rota foi armazenada já sem o prefixo custom:<id>/. Hoje isso não acontece, porque a escrita estrita sempre grava o ID interno, então é só uma observação.

Resumo

Não há blocker nem critério de risco alto. Fica um should-fix (a regressão de ambiguidade), que precisa ser corrigido neste PR ou virar issue com o link registrado no PR antes do merge. Como afeta comandos que funcionavam, recomendo corrigir aqui. O CI está verde neste SHA. O merge também depende do qa-docker e da saída de draft.

Head revisado: 2501ecc
Veredito: aprovado

…tability

Addresses the second review of #190 (head 2501ecc).

- Params model selector: match in layers and stop at the first with a
  hit: (1) the stored id (Anthropic short ids normalized), (2) the public
  id, (3) the derived native names. An id that names one route exactly
  (e.g. OpenRouter's stored "openai/gpt-4o") is no longer made ambiguous
  by another route's derived name. Ambiguity is only declared inside the
  winning layer, and the error suggests only names (public id, then
  stored id) that select a single connection.
- Virtual model views gain `routable`, computed with
  virtualModelRoutable on the chain the proxy builds (own routes, then
  only the `default` slot with continue_on_default: override, legacy
  auto-assigned route or an available fallback), filtered by the
  catalog snapshot like context_length. thinker_routing_status and
  `thinker routing status` both derive routes_by_virtual_model and
  active_virtual_models from it, so an empty default slot with routes in
  another slot no longer reads as routable.
- Tests: layered matching for virtual models and tiers, unique-only
  suggestions, virtualModelRoutable rules, MCP/CLI status from the flag,
  and an e2e with an empty default slot and a routed `simple` slot.

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

Copy link
Copy Markdown
Owner Author

Re-revisão: PR #190 (head cf7eb89)

Escopo: o delta 2501ecc..cf7eb89 (um commit) e o diff completo origin/main...cf7eb89. O merge-base continua 257ebc4. O GitHub marca o PR como MERGEABLE com main em 7f4a90c.

CI no SHA revisado (check-runs do commit): os 19 checks estão verdes. Entre eles estão os dois Backend e2e PG16, install.sh end-to-end, Compose smoke, Docker amd64/arm64, CodeQL e o unit da CLI com o drift guard da skill.

qa-docker: continua obrigatório neste head antes de sair de draft e antes do merge.

1. Casamento em camadas: resolvido

matchRoutes tenta, em ordem, o ID armazenado (com a normalização Anthropic de antes), depois o ID público e por fim os nomes nativos derivados. A primeira camada que encontrar algo vence, e a ambiguidade só é avaliada dentro dela.

  • No caso que eu apontei (openai/gpt-4o com uma rota direta e outra pela OpenRouter), a OpenRouter vence pelo ID armazenado, como em main.
  • As sugestões só trazem nomes que selecionam uma única conexão (unique).
  • Há testes para os dois casos.

Tradeoff declarado (um gpt-4o sem prefixo casa com o ID armazenado da rota não custom antes do custom de mesmo nome nativo): aceitável.

  • É o comportamento de main e de cbafbf6, onde só o ID armazenado casava. Quem mudou foi o 2501ecc.
  • O usuário digitou literalmente o ID armazenado de uma rota.
  • A resposta mostra route (provider, authType, model) e public_model, então a rota escolhida fica visível.
  • O custom continua alcançável pelo ID público ou pelo ID armazenado.
  • Não há gravação silenciosa em uma rota que o nome não designa.

2. routes_by_virtual_model

  • Definição única: resolvida. O MCP e a CLI agora leem o routable calculado no servidor por virtualModelRoutable. Ele monta a mesma cadeia do proxy (as rotas próprias e, com continue_on_default, só o slot default) e aplica os mesmos filtros de disponibilidade e transporte do context_length.
  • routable? opcional no tipo compartilhado: não quebra o frontend. O dashboard usa VirtualModelView (VirtualModel = VirtualModelView) e não lê o campo. Um campo opcional não exige nada dos fixtures nem do código existente. O CI de frontend está verde.

Should-fix

1. virtual-model-chain.ts (virtualModelRoutable): diz "roteável" quando o provedor da rota foi desconectado, e o proxy responde M301.

O problema está no tratamento do estado "desconhecido" do snapshot. chainContextWindow só descarta rotas no estado unavailable. snapshotRouteState devolve unknown sempre que a conexão não tem nenhum modelo no snapshot, e é exatamente isso que acontece quando o provedor foi removido ou desabilitado para o agente, porque getModelsForAgent deixa de listá-lo.

Um exemplo concreto:

  • o virtual deepseek/flash tem só rotas deepseek/*;
  • o operador desconecta o DeepSeek, mas mantém outro provedor ativo (então o status não é no_provider);
  • routable continua true, e thinker_routing_status e thinker routing status informam routes_by_virtual_model: true;
  • no proxy, isRouteAvailable cai no fallback de registros (provider-key.service.ts:435-446), não encontra o provedor e devolve false, e o pedido termina em M301.

Esse é justamente o falso positivo que o critério "nunca dizer roteável quando o proxy daria M301" quer evitar. O teste "never counts … one whose routes are all gone" só cobre um modelo sumido de um provedor que continua conectado (estado unavailable), não um provedor removido.

Correção sugerida: para routable, tratar unknown como o fallback de isRouteAvailable trata. A rota só conta se existir um registro ativo e utilizável do provedor para o agente. toViews pode carregar uma vez providerKeyService/getProviders (o status do MCP já busca os provedores) e passar um predicado para virtualModelRoutable. Em descoberta fria, com o provedor conectado mas sem modelos ainda, a rota continua contando, como hoje. Falta também um teste com o provedor removido.

Se preferirem não carregar os provedores, uma alternativa é publicar um estado tri-state (routable: true | false | null) e tratar null como "não confirmado" no status.

Resumo

Não há blocker nem critério de risco alto. O achado da camada de casamento ficou resolvido. Fica 1 should-fix (o falso positivo de routable com provedor removido), que precisa ser corrigido neste PR ou virar issue com o link registrado no PR antes do merge. O CI está verde neste SHA. O merge depende também do qa-docker e da saída de draft.

Head revisado: cf7eb89
Veredito: aprovado

@patrickbrandao

Copy link
Copy Markdown
Owner Author

O should-fix da terceira revisão (routable com provedor desconectado) foi registrado em #197, conforme a ADR 0002: afeta só o status informativo do MCP/CLI, não o roteamento.

@patrickbrandao
patrickbrandao marked this pull request as ready for review October 26, 2026 13:22
@patrickbrandao
patrickbrandao merged commit fd8e7ad into main Oct 26, 2026
19 checks passed
@patrickbrandao
patrickbrandao deleted the feat/virtual-models-mcp-cli branch October 26, 2026 13:22
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