Skip to content

Roteamento & default

O Ongrid roda N providers em paralelo e despacha cada chamada LLM a exatamente um. Esta página cobre como esse dispatch funciona em três camadas:

  1. MultiClient — o router de nível wire usado pelo caminho legado llm.Chat (translate, busca de conhecimento).
  2. RoutingChatModel — o wrapper eino model.ChatModel usado pelo agent ReAct de graph-kernel.
  3. DefaultResolver — o hook de default-dinâmico que deixa o setting default_provider ter efeito mid-process.

MultiClient

O fundo da stack do router vive em internal/pkg/llm/router.go.

go
// router.go:67
type MultiClient struct {
    // map: provider id -> sub-Client built from a ProviderConfig
    // ...
}

func (m *MultiClient) Chat(ctx context.Context, req ChatReq) (*ChatResp, error)

Cada config de provider produz um sub-client na construção:

go
// cmd/ongrid/main.go:492
providerCfgs := []llm.ProviderConfig{}
if cfg.OpenAI.APIKey != "" {
    providerCfgs = append(providerCfgs, llm.ProviderConfig{
        ID: "openai", Label: "OpenAI",
        APIKey: cfg.OpenAI.APIKey,
        Model: firstNonEmpty(cfg.OpenAI.Model, "gpt-5.4"),
        BaseURL: cfg.OpenAI.BaseURL,
        Models: dedupeModels(...),
    })
}
// ...mesmo para cada provider...
llmRouter := llm.NewMultiClient(providerCfgs, cfg.LLM.Default, openaiClient)

Chat despacha em req.Provider:

  • Não-vazio → lookup do sub-client; 404 → ErrUnknownProvider.
  • Vazio → cai para defaultProvider.

A fiação MultiClient.SetProvidersResolver(r) camada o resolver DB-backed por cima — em cada call o conjunto de sub-client ativo é re-resolvido (cacheado 60s por SetResolveTTL).

RoutingChatModel

O graph kernel usa a interface model.ChatModel do eino, não llm.Chat. RoutingChatModel (eino_routing.go:89) envolve N ChatModels internos e despacha via uma opção impl-específica:

go
type RoutingChatModel struct {
    inner           map[string]model.ChatModel
    defaultProvider string
    defaultResolver func(context.Context) (provider, mdl string)
}

func WithProvider(provider string) model.Option {
    return model.WrapImplSpecificOptFn(func(o *providerOpts) {
        o.provider = provider
    })
}

Uso de um call site:

go
resp, err := chatModel.Generate(ctx, msgs,
    model.WithModel("glm-4.7-flash"),
    llm.WithProvider("zhipu"),
)

pick() resolve o inner:

go
// eino_routing.go:173
func (r *RoutingChatModel) pick(opts ...model.Option) (model.ChatModel, string, error) {
    po := model.GetImplSpecificOptions(&providerOpts{}, opts...)
    prov := po.provider
    if prov == "" {
        prov = r.defaultProvider
    }
    inner, ok := r.inner[prov]
    if !ok {
        return nil, prov, fmt.Errorf("%w: %s", ErrUnknownProvider, prov)
    }
    return inner, prov, nil
}

DefaultResolver — o default dinâmico

O bug que isso conserta: um admin vira default_provider de Anthropic para GLM em /settings/llm. A UI do picker de chat imediatamente respeita o novo default (re-fetcha /v1/aiops/models em cada load). Mas o worker investigator do RCA — que é interno ao processo e fixa seu provider no boot — continua roteando para Anthropic até restart.

Fix: RoutingChatModelConfig.DefaultResolver.

go
var defaultResolver func(context.Context) (string, string)
if resolver != nil {
    defaultResolver = func(rctx context.Context) (string, string) {
        provCfgs, resolvedDefault, rerr := resolver.ResolveProviders(rctx)
        if rerr != nil || resolvedDefault == "" {
            return "", ""
        }
        for _, pc := range provCfgs {
            if pc.ID == resolvedDefault {
                return resolvedDefault, pc.Model
            }
        }
        return resolvedDefault, ""
    }
}
chatModel, err := llm.NewRoutingChatModel(llm.RoutingChatModelConfig{
    Inner:           innerModels,
    DefaultProvider: defProv,
    DefaultResolver: defaultResolver,
})

withDynamicDefault injeta a saída do resolver como WithProvider + WithModel para chamadas que não fixaram nenhum — o picker de chat fixa provider por-mensagem e bypassa o resolver inteiramente; chamadas default-routed (investigator, translate, fan-out de knowledge) agora seguem configuração live.

Provider por-call em Chat

Para o caminho MultiClient.Chat (callers não-graph-kernel), defina ChatReq.Provider explicitamente:

go
resp, err := llmClient.Chat(ctx, llm.ChatReq{
    Provider: "openai",
    Model:    "gpt-5.5",
    Messages: msgs,
})

Provider vazio → MultiClient resolve o default no tempo da call. Model vazio → o sub-client resolvido usa seu default configurado.

Provider por-call no graph kernel

O runtime do agent injeta llm.WithProvider sempre que o envelope de envio do chat contém um provider não-vazio. O registry de persona também pode fixar um provider para uma persona inteira (ex.: a persona extractor barata fixa em Anthropic Haiku). Veja a referência de formato de persona do agent.

Armadilhas

  • Esquecer de default_provider — o resolver escolhe o primeiro provider id ordenado; você vai mandar seu model id para o endpoint errado. Sempre defina ONGRID_LLM_DEFAULT_PROVIDER (ou escreva a linha do DB).
  • Fixar um provider que não tem ChatModel interno — acontece quando o resolver retorna um provider id que não foi registrado no boot E não foi pré-registrado. O slot custom é pré-registrado exatamente por isso; tudo mais gateia em cfg.LLM.*.APIKey != "".
  • Timing de hot-swap — o TTL de cache é 60s no resolver e 60s no MultiClient. Pior caso 120s antes de um edit do admin ter efeito. O caminho Invalidate é exposto mas não conectado à ação de save do SPA ainda.

Veja também

  • Visão geral dos modelos — montagem de []llm.ProviderConfig.
  • Budget — ortogonal ao roteamento; mesmo cap se aplica a cada provider.
  • RCA — roteamento do worker investigator.