Skip to content

Routing & default

Ongrid corre N providers en paralelo y hace dispatch de cada llamada LLM a exactamente uno. Esta página cubre cómo funciona ese dispatch en tres capas:

  1. MultiClient — el router a nivel wire usado por la ruta legacy llm.Chat (translate, búsqueda de conocimiento).
  2. RoutingChatModel — el wrapper de eino model.ChatModel usado por el agente ReAct con graph-kernel.
  3. DefaultResolver — el hook de default dinámico que deja que la configuración default_provider tome efecto en medio del proceso.

MultiClient

El fondo del stack del router vive en 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 provider config produce un sub-cliente en construcción:

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(...),
    })
}
// ...same for each provider...
llmRouter := llm.NewMultiClient(providerCfgs, cfg.LLM.Default, openaiClient)

Chat hace dispatch sobre req.Provider:

  • No vacío → busca el sub-cliente; 404 → ErrUnknownProvider.
  • Vacío → cae a defaultProvider.

El cableado MultiClient.SetProvidersResolver(r) capa el resolver respaldado por DB encima — en cada llamada el set de sub-cliente activo se re-resuelve (cached 60s por SetResolveTTL).

RoutingChatModel

El graph kernel usa la interfaz model.ChatModel de eino, no llm.Chat. RoutingChatModel (eino_routing.go:89) envuelve N ChatModels internos y hace dispatch vía una opción 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 desde un call site:

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

pick() resuelve el 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 — el default dinámico

El bug que esto arregla: un admin flippea default_provider de Anthropic a GLM en /settings/llm. La UI del picker de chat inmediatamente respeta el nuevo default (re-fetchea /v1/aiops/models en cada load). Pero el worker investigator RCA — que es process-internal y vincula su provider al boot — sigue ruteando a Anthropic hasta reiniciar.

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 inyecta la salida del resolver como WithProvider + WithModel para llamadas que no pinearon ninguno — el picker de chat pinea provider por-mensaje y bypassa al resolver totalmente; las llamadas default-routed (investigator, translate, fan-out de conocimiento) ahora siguen la configuración en vivo.

Provider por-llamada en Chat

Para la ruta MultiClient.Chat (callers no-graph-kernel), setea ChatReq.Provider explícitamente:

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

Provider vacío → MultiClient resuelve el default en tiempo de llamada. Model vacío → el sub-cliente resuelto usa su default configurado.

Provider por-llamada en el graph kernel

El agent runtime inyecta llm.WithProvider cada vez que el envelope de chat send contiene un provider no vacío. El registry de personas también puede pinear un provider para una persona entera (p. ej. la persona extractor barata pinea a Anthropic Haiku). Ver la referencia de formato de persona de agente.

Trampas

  • Olvidarse de default_provider — el resolver elige el primer provider id ordenado; enviarás tu id de modelo al endpoint equivocado. Siempre setea ONGRID_LLM_DEFAULT_PROVIDER (o escribe la fila DB).
  • Pinear un provider que no tiene ChatModel interno — pasa cuando el resolver devuelve un provider id que no fue registrado al boot Y no fue pre-registrado. El slot custom está pre-registrado por exactamente esta razón; todo lo demás gateaa en cfg.LLM.*.APIKey != "".
  • Timing de hot-swap — el TTL de cache es 60s en el resolver y 60s en el MultiClient. En el peor caso 120s antes de que una edición de admin tome efecto. La ruta Invalidate está expuesta pero no cableada a la acción save de la SPA aún.

Ver también

  • Overview de modelos — ensamblaje de []llm.ProviderConfig.
  • Budget — ortogonal al routing; el mismo tope aplica a cada provider.
  • RCA — routing del worker investigator.