mirror of
https://github.com/coder/coder.git
synced 2026-10-31 19:36:41 +08:00
Centralizes every LLM client build in chatd behind one resolver and makes all model calls behave the same, instead of preserving per-flow quirks. ## Why `chatd` built LLM clients in five places (standard turn, compaction override, quickgen, title override, advisor), each repeating config parsing, route resolution, client construction, and debug wrapping with slightly different behavior. An earlier iteration of this PR centralized construction but preserved every historical quirk behind purpose-specific spec constructors and policy flags. Review concluded the quirks themselves were the problem. ## What changed - `Server.resolveModelCall` (`modelcall.go`) is the single pipeline from a `modelCallSpec` to a ready client plus call metadata. Callers build spec literals inline; the only spec fields are data: model source (chat config, explicit config row, or fixed provider/model pair for computer use), requested reasoning effort, chatd-scoped routing, and build options. - Uniform behavior for every call: - Provider options are always derived. Whole-chat summaries, turn status labels, and chat-model compaction summaries previously sent none; they now follow the config like everything else. - `MaxOutputTokens` always defaults to 32k when the config leaves it unset. The compaction summary call nulls the cap after copying the template because it is non-streaming and the Anthropic SDK rejects non-streaming requests whose cap implies a completion over ten minutes. - Debug recording always wraps the client when chat debug is enabled, labeled with the resolved provider/model. The debug-rebuild paths (`manualTitleDebugSpec`, `quickgenDebugSpec`) are deleted; debug helpers now only create and finalize run records, since run association flows through the generate context, not the model. - Clients are always constructed with the configured model string and unmutated route, so AI Gateway validation sees the name exactly as configured. The resolved identity feeds metadata, logs, and debug labels only. - `resolvedModelCall.newCall` / `newObjectCall` are the only production constructors of `fantasy.Call` / `fantasy.ObjectCall`; object calls now carry the config's sampling parameters. - Net: -240 lines. ## Validation - Full `./coderd/x/chatd/...` suite green (PostgreSQL-backed). - Red-green toggles verified for the three key unifications (option derivation, token default, summary cap removal). - ARCHITECTURE.md: prose the refactor made stale is deleted; no new resolver documentation is added. > Mux acted on Mike's behalf for this PR.
157 lines
4.8 KiB
Go
157 lines
4.8 KiB
Go
package chatadvisor
|
|
|
|
import (
|
|
"sync/atomic"
|
|
|
|
"charm.land/fantasy"
|
|
fantasyopenai "charm.land/fantasy/providers/openai"
|
|
"golang.org/x/xerrors"
|
|
)
|
|
|
|
// RuntimeConfig configures a single advisor runtime instance.
|
|
type RuntimeConfig struct {
|
|
Model fantasy.LanguageModel
|
|
// CallTemplate's provider options are cloned for each nested call.
|
|
CallTemplate fantasy.Call
|
|
MaxUsesPerRun int
|
|
MaxOutputTokens int64
|
|
}
|
|
|
|
// Runtime executes nested, tool-less advisor runs against the configured
|
|
// language model.
|
|
//
|
|
// Each Runtime instance is scoped to a single outer chat run. The
|
|
// MaxUsesPerRun counter increments on every successful advisor call and
|
|
// is never reset, so callers must construct a fresh Runtime (via
|
|
// NewRuntime) for each outer run. There is intentionally no Reset method:
|
|
// the per-run quota is a safety bound on a single run, not a rolling
|
|
// window.
|
|
type Runtime struct {
|
|
cfg RuntimeConfig
|
|
used atomic.Int64
|
|
}
|
|
|
|
// NewRuntime validates and normalizes advisor runtime configuration.
|
|
func NewRuntime(cfg RuntimeConfig) (*Runtime, error) {
|
|
if cfg.Model == nil {
|
|
return nil, xerrors.New("advisor model is required")
|
|
}
|
|
if cfg.MaxUsesPerRun <= 0 {
|
|
return nil, xerrors.New("advisor max uses per run must be positive")
|
|
}
|
|
if cfg.MaxOutputTokens <= 0 {
|
|
return nil, xerrors.New("advisor max output tokens must be positive")
|
|
}
|
|
if cfg.CallTemplate.MaxOutputTokens != nil &&
|
|
*cfg.CallTemplate.MaxOutputTokens != cfg.MaxOutputTokens {
|
|
return nil, xerrors.Errorf(
|
|
"advisor call template max output tokens (%d) must match runtime max output tokens (%d)",
|
|
*cfg.CallTemplate.MaxOutputTokens,
|
|
cfg.MaxOutputTokens,
|
|
)
|
|
}
|
|
|
|
normalized := cfg
|
|
normalized.CallTemplate.ProviderOptions = cloneProviderOptions(cfg.CallTemplate.ProviderOptions)
|
|
maxOutputTokens := cfg.MaxOutputTokens
|
|
normalized.CallTemplate.MaxOutputTokens = &maxOutputTokens
|
|
|
|
return &Runtime{cfg: normalized}, nil
|
|
}
|
|
|
|
// cloneProviderOptions returns a copy of opts with pointer entries for
|
|
// known, in-place mutated provider option types replaced by shallow struct
|
|
// copies, so a nested advisor call that disables Store does so on its own
|
|
// copy rather than the parent run's entry. Value fields such as Metadata
|
|
// and Include remain shared; callers that need true deep-copy semantics
|
|
// must handle those fields explicitly.
|
|
func cloneProviderOptions(opts fantasy.ProviderOptions) fantasy.ProviderOptions {
|
|
if opts == nil {
|
|
return nil
|
|
}
|
|
cloned := make(fantasy.ProviderOptions, len(opts))
|
|
for key, value := range opts {
|
|
switch typed := value.(type) {
|
|
case *fantasyopenai.ResponsesProviderOptions:
|
|
if typed == nil {
|
|
cloned[key] = value
|
|
continue
|
|
}
|
|
copied := *typed
|
|
cloned[key] = &copied
|
|
default:
|
|
cloned[key] = value
|
|
}
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
// resetProviderOptionsForNestedCall forces Store off so ephemeral advisor
|
|
// calls leave no stored response behind on the provider. It mutates opts
|
|
// in place, so it must be called on a cloned map, never on options shared
|
|
// with the parent run.
|
|
func resetProviderOptionsForNestedCall(opts fantasy.ProviderOptions) {
|
|
for _, value := range opts {
|
|
if typed, ok := value.(*fantasyopenai.ResponsesProviderOptions); ok && typed != nil {
|
|
storeDisabled := false
|
|
typed.Store = &storeDisabled
|
|
}
|
|
}
|
|
}
|
|
|
|
// RemainingUses reports how many advisor calls are still available for the
|
|
// current runtime.
|
|
func (rt *Runtime) RemainingUses() int {
|
|
if rt == nil || rt.cfg.MaxUsesPerRun <= 0 {
|
|
return 0
|
|
}
|
|
|
|
remaining := int64(rt.cfg.MaxUsesPerRun) - rt.used.Load()
|
|
if remaining < 0 {
|
|
return 0
|
|
}
|
|
return int(remaining)
|
|
}
|
|
|
|
// MaxOutputTokens reports the resolved output-token cap applied to each
|
|
// advisor call. NewRuntime validates that this value is positive and that
|
|
// it matches ModelConfig.MaxOutputTokens when both are set, so the
|
|
// accessor always returns the value the runtime will actually send.
|
|
func (rt *Runtime) MaxOutputTokens() int64 {
|
|
if rt == nil {
|
|
return 0
|
|
}
|
|
return rt.cfg.MaxOutputTokens
|
|
}
|
|
|
|
// ProviderOptions reports the resolved provider options applied to each
|
|
// advisor call. NewRuntime clones the supplied options so the returned
|
|
// map reflects what nested calls will actually receive; callers must not
|
|
// mutate the map or its entries.
|
|
func (rt *Runtime) ProviderOptions() fantasy.ProviderOptions {
|
|
if rt == nil {
|
|
return nil
|
|
}
|
|
return rt.cfg.CallTemplate.ProviderOptions
|
|
}
|
|
|
|
func (rt *Runtime) tryAcquire() bool {
|
|
for {
|
|
used := rt.used.Load()
|
|
if used >= int64(rt.cfg.MaxUsesPerRun) {
|
|
return false
|
|
}
|
|
if rt.used.CompareAndSwap(used, used+1) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// release returns a previously acquired use to the pool. Callers must
|
|
// invoke this at most once per successful tryAcquire when the advisor
|
|
// call did not complete successfully, so a transient provider failure
|
|
// does not permanently consume quota for the run.
|
|
func (rt *Runtime) release() {
|
|
rt.used.Add(-1)
|
|
}
|