@@ -23,6 +23,7 @@ import (
2323 "github.com/charmbracelet/crush/internal/agent/prompt"
2424 "github.com/charmbracelet/crush/internal/agent/tools"
2525 "github.com/charmbracelet/crush/internal/config"
26+ "github.com/charmbracelet/crush/internal/csync"
2627 "github.com/charmbracelet/crush/internal/discover"
2728 "github.com/charmbracelet/crush/internal/event"
2829 "github.com/charmbracelet/crush/internal/filetracker"
@@ -123,6 +124,14 @@ type coordinator struct {
123124 activeSkills []*skills.Skill // Post-filter: active skills only.
124125 skillTracker *skills.Tracker
125126
127+ // providerClients caches built provider clients keyed by their fully
128+ // resolved configuration. UpdateModels rebuilds models every turn;
129+ // without this, each turn discards the provider's HTTP client and its
130+ // keep-alive connection pool, forcing a fresh TLS handshake. The key
131+ // captures every input buildProvider depends on, so any config/model/
132+ // credential change misses the cache and rebuilds.
133+ providerClients *csync.Map[string, fantasy.Provider]
134+
126135 readyWg errgroup.Group
127136}
128137
@@ -166,6 +175,8 @@ func NewCoordinator(
166175 allSkills: allSkills,
167176 activeSkills: activeSkills,
168177 skillTracker: skillTracker,
178+
179+ providerClients: csync.NewMap[string, fantasy.Provider](),
169180 }
170181
171182 agentCfg, ok := cfg.Config().Agents[config.AgentCoder]
@@ -1043,6 +1054,66 @@ func (c *coordinator) buildProvider(providerCfg config.ProviderConfig, model con
10431054 apiKey, _ := c.cfg.Resolve(providerCfg.APIKey)
10441055 baseURL, _ := c.cfg.Resolve(providerCfg.BaseURL)
10451056
1057+ // Reuse a previously built client when nothing that affects it has
1058+ // changed, so the HTTP keep-alive pool survives across turns. The key
1059+ // is derived from the resolved inputs above (plus the debug flag); the
1060+ // per-provider adjustments inside the switch below are deterministic
1061+ // functions of these, so identical inputs always yield an identical
1062+ // client.
1063+ cacheKey := c.providerCacheKey(providerCfg, model, apiKey, baseURL, headers, isSubAgent)
1064+ if p, ok := c.providerClients.Get(cacheKey); ok {
1065+ return p, nil
1066+ }
1067+ provider, err := c.buildProviderUncached(providerCfg, model, apiKey, baseURL, headers, isSubAgent)
1068+ if err != nil {
1069+ return nil, err
1070+ }
1071+ c.providerClients.Set(cacheKey, provider)
1072+ return provider, nil
1073+}
1074+
1075+// providerCacheKey builds a stable key from every input buildProvider's
1076+// switch consumes, so any credential, endpoint, header, or option change
1077+// produces a distinct key (and thus a rebuilt client).
1078+func (c *coordinator) providerCacheKey(providerCfg config.ProviderConfig, model config.SelectedModel, apiKey, baseURL string, headers map[string]string, isSubAgent bool) string {
1079+ var b strings.Builder
1080+ b.WriteString(providerCfg.ID)
1081+ b.WriteByte(0)
1082+ b.WriteString(string(providerCfg.Type))
1083+ b.WriteByte(0)
1084+ // The opencode providers pick their client type from the model, so the
1085+ // model must participate in the key.
1086+ b.WriteString(model.Model)
1087+ b.WriteByte(0)
1088+ b.WriteString(apiKey)
1089+ b.WriteByte(0)
1090+ b.WriteString(baseURL)
1091+ b.WriteByte(0)
1092+ for _, k := range slices.Sorted(maps.Keys(headers)) {
1093+ b.WriteString(k)
1094+ b.WriteByte('=')
1095+ b.WriteString(headers[k])
1096+ b.WriteByte(1)
1097+ }
1098+ b.WriteByte(0)
1099+ if extra, err := json.Marshal(providerCfg.ExtraBody); err == nil {
1100+ b.Write(extra)
1101+ }
1102+ b.WriteByte(0)
1103+ if extra, err := json.Marshal(providerCfg.ExtraParams); err == nil {
1104+ b.Write(extra)
1105+ }
1106+ b.WriteByte(0)
1107+ if isSubAgent {
1108+ b.WriteByte('s')
1109+ }
1110+ if c.cfg.Config().Options.Debug {
1111+ b.WriteByte('d')
1112+ }
1113+ return b.String()
1114+}
1115+
1116+func (c *coordinator) buildProviderUncached(providerCfg config.ProviderConfig, model config.SelectedModel, apiKey, baseURL string, headers map[string]string, isSubAgent bool) (fantasy.Provider, error) {
10461117 switch providerCfg.ID {
10471118 case string(catwalk.InferenceProviderOpenCodeGo), string(catwalk.InferenceProviderOpenCodeZen):
10481119 if opencodeMessagesModels[model.Model] {