Skip to content

Routing & défaut

Ongrid exécute N providers en parallèle et dispatch chaque appel LLM à exactement un. Cette page couvre comment ce dispatch fonctionne à trois couches :

  1. MultiClient — le routeur au niveau du wire utilisé par le chemin legacy llm.Chat (translate, recherche dans la base de connaissances).
  2. RoutingChatModel — le wrapper eino model.ChatModel utilisé par l'agent ReAct graph-kernel.
  3. DefaultResolver — le hook de défaut dynamique qui fait que le réglage default_provider prend effet en cours de processus.

MultiClient

Le bas de la stack du routeur vit dans 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)

Chaque config de provider produit un sous-client à la construction :

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 dispatch sur req.Provider :

  • Non vide → recherche du sous-client ; 404 → ErrUnknownProvider.
  • Vide → fallback sur defaultProvider.

Le câblage MultiClient.SetProvidersResolver(r) superpose le resolver adossé à la DB par-dessus — à chaque appel, l'ensemble de sous-clients actif est re-résolu (caché 60s par SetResolveTTL).

RoutingChatModel

Le graph kernel utilise l'interface model.ChatModel d'eino, pas llm.Chat. RoutingChatModel (eino_routing.go:89) wrappe N ChatModels internes et dispatch via une option impl-spécifique :

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
    })
}

Usage depuis un site d'appel :

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

pick() résout l'interne :

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 — le défaut dynamique

Le bug que ceci corrige : un admin bascule default_provider d'Anthropic à GLM dans /settings/llm. L'UI du picker de chat respecte immédiatement le nouveau défaut (elle re-fetch /v1/aiops/models à chaque chargement). Mais le worker investigator RCA — qui est interne au processus et lie son provider au boot — continue à router vers Anthropic jusqu'au redémarrage.

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 injecte la sortie du resolver comme WithProvider + WithModel pour les appels qui n'ont épinglé ni l'un ni l'autre — le picker de chat épingle le provider par-message et bypass le resolver entièrement ; les appels routés par défaut (investigator, translate, fan-out de base de connaissances) suivent maintenant la configuration en direct.

Provider par appel dans Chat

Pour le chemin MultiClient.Chat (appelants non-graph-kernel), définissez ChatReq.Provider explicitement :

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

Provider vide → MultiClient résout le défaut au moment de l'appel. Model vide → le sous-client résolu utilise son défaut configuré.

Provider par appel dans le graph kernel

Le runtime de l'agent injecte llm.WithProvider chaque fois que l'enveloppe d'envoi du chat contient un provider non vide. Le registre de persona peut aussi épingler un provider pour toute une persona (par ex. la persona extracteur bon marché s'épingle à Anthropic Haiku). Voir la référence du format de persona d'agent.

Pièges

  • Oublier default_provider — le resolver choisit le premier id de provider trié ; vous enverrez votre id de modèle au mauvais endpoint. Définissez toujours ONGRID_LLM_DEFAULT_PROVIDER (ou écrivez la ligne DB).
  • Épingler un provider qui n'a pas de ChatModel interne — arrive quand le resolver retourne un id de provider qui n'a pas été enregistré au boot ET n'a pas été pré-enregistré. Le slot custom est pré-enregistré exactement pour cette raison ; tout le reste dépend de cfg.LLM.*.APIKey != "".
  • Timing de hot-swap — le TTL du cache est de 60s sur le resolver et 60s sur le MultiClient. Pire cas 120s avant qu'un edit admin prenne effet. Le chemin Invalidate est exposé mais pas encore câblé à l'action de sauvegarde de la SPA.

Voir aussi

  • Vue d'ensemble des modèles — assemblage de []llm.ProviderConfig.
  • Budget — orthogonal au routing ; le même plafond s'applique à chaque provider.
  • RCA — routing du worker investigator.