Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
082caab
feat(extensions): add provider protocol and offline runtime path
Oct 22, 2026
af620cb
fix(extensions): preserve replay parts and snapshot provider state
Oct 22, 2026
bf25908
fix(extensions): track owned runtimes through retirement and close
Oct 22, 2026
6203198
fix(extensions): retain source identity for inherited providers
Oct 22, 2026
7ee05a1
test(extensions): cover reload rollback and concurrent emission
Oct 22, 2026
129c96b
fix(extensions): stop iterator delivery after consumer cancellation
Oct 22, 2026
972bfbb
test(extensions): validate provider metadata and settings defaults
Oct 22, 2026
c2848fa
fix(extensions): retain auth callback owners and copy price metadata
Oct 22, 2026
87b5cc9
fix(extensions): retain finish metadata and cancellation identity
Oct 22, 2026
e5f722e
feat(extensions): expose authoritative capability and limit flags
Oct 22, 2026
4995029
fix(extensions): retain origin on host tool results for persistence
Oct 22, 2026
c98e58e
fix(extensions): reject new runtime leases during final shutdown
Oct 22, 2026
0aeaec3
fix(extensions): carry message replay metadata into saved steps
Oct 22, 2026
a637b4e
fix(extensions): reject reentrant shutdown before retiring owners
Oct 22, 2026
a067d86
fix(extensions): preserve safe provider error codes and messages
Oct 22, 2026
5e3c2d1
test(extensions): load provider callbacks through public harness
Oct 22, 2026
0789eb5
fix(extensions): redact transient keys in create error codes
Oct 22, 2026
8cf1ce5
feat(extensions): use instance reasoning levels in selector controls
Oct 22, 2026
ee05945
test(extensions): assert stored tool call and result order
Oct 22, 2026
f2cd569
fix(extensions): preserve finish metadata in collected responses
Oct 22, 2026
d124d55
fix(extensions): prevent replay after reasoning start exposure
Oct 22, 2026
547ffb0
fix(extensions): close runtimes created during shutdown race
Oct 22, 2026
97edcd7
test(extensions): verify host hooks and reject decision operations
Oct 22, 2026
a122fbc
fix(extensions): use instance pricing on switches and enforce choice
Oct 22, 2026
f6e5549
fix(extensions): classify structured backend error codes
Oct 22, 2026
2483f28
fix(extensions): exclude extension failures from overflow replay
Oct 22, 2026
db99065
test(extensions): reject native and factory provider collisions
Oct 22, 2026
9739c01
docs(extensions): clarify discovery and retirement phase ordering
Oct 22, 2026
a5027d8
test(extensions): prevent retries after text reasoning and tool output
Oct 22, 2026
69978c7
test(extensions): feed host catalog and reasoning choices into UI
Oct 22, 2026
526258a
fix(extensions): resolve explicit unknown-model capability defaults
Oct 22, 2026
e004221
fix(extensions): redact transient keys from runtime close failures
Oct 22, 2026
465ef7e
fix(extensions): reject duplicate exposed tool definitions
Oct 22, 2026
63b8868
fix(extensions): honor declared audio and video file modalities
Oct 22, 2026
2dab88f
fix(extensions): print catalog entries on separate lines
Oct 22, 2026
930daed
test(extensions): validate preflight and credential redaction
Oct 22, 2026
b34e692
fix(extensions): keep unknown provider pricing distinct from free
Oct 22, 2026
3448bc1
fix(extensions): validate inherited thinking during model switches
Oct 22, 2026
441483b
test(extensions): enforce provider symbol table completeness
Oct 22, 2026
ed0105f
test(extensions): account cumulative usage snapshots exactly once
Oct 22, 2026
30af934
test(extensions): reopen sessions with reasoning and message replay
Oct 22, 2026
976cff9
fix(extensions): recheck external authentication before create
Oct 22, 2026
b2e32ab
fix(extensions): reject stale reload publication during shutdown
Oct 22, 2026
41f0241
fix(extensions): reject reentrant reload from active provider callback
Oct 22, 2026
5a49015
fix(extensions): preserve block start order across interleaved output
Oct 22, 2026
285eb33
fix(extensions): label pending picker authentication as checking
Oct 22, 2026
2c46995
fix(extensions): validate provider settings during registration
Oct 22, 2026
542d047
fix(extensions): cancel creation and auth leases during shutdown
Oct 22, 2026
c8bb308
test(extensions): drain creation leases after bounded shutdown
Oct 22, 2026
bfd6fa7
fix(extensions): reject shutdown reentry from runtime close callbacks
Oct 22, 2026
d2aea14
fix(extensions): validate tool schemas without external resolution
Oct 22, 2026
4e2117d
fix(extensions): validate assembled calls against exposed schemas
Oct 22, 2026
63516b6
fix(extensions): reject mismatched event payload fields
Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
version: "2"

linters:
exclusions:
rules:
# Keep the third-party interpreter diff limited to the concurrency fix.
# Kit-owned integration and regression tests remain fully linted.
- path: internal/yaegi/
linters: [errcheck, govet, modernize, staticcheck, unused]
enable:
- modernize
# staticcheck and unused catch bugs and dead code that went undetected
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@

# KIT (Knowledge Inference Tool)

## Extension provider guide

See [callback-based model providers](www/pages/extensions/providers.md) for the offline examples and current implementation status.

## SDK guides

- [Decision API](https://go-kit.dev/sdk/decisions): typed probabilistic questions, not chat.
Expand Down
35 changes: 35 additions & 0 deletions cmd/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import (
"fmt"
"sort"

"github.com/mark3labs/kit/internal/extensions"
"github.com/mark3labs/kit/internal/kitsetup"
"github.com/mark3labs/kit/internal/models"
kit "github.com/mark3labs/kit/pkg/kit"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)

var modelsAllFlag bool
var modelsExtensionsFlag bool

var modelsCmd = &cobra.Command{
Use: "models [provider]",
Expand All @@ -36,10 +40,41 @@ Examples:

func init() {
modelsCmd.Flags().BoolVar(&modelsAllFlag, "all", false, "show all providers in the database, not just fantasy-compatible ones")
modelsCmd.Flags().BoolVar(&modelsExtensionsFlag, "extensions", false, "execute trusted extension Init code and include its declared models")
rootCmd.AddCommand(modelsCmd)
}

func runModels(_ *cobra.Command, args []string) error {
if modelsExtensionsFlag && !viper.GetBool("no-extensions") {
loaded, err := extensions.LoadExtensionsScoped(viper.GetStringSlice("extension"), bareFlag)
if err != nil {
return err
}
if _, err := kitsetup.PrepareProviderFactories(loaded, nil); err != nil {
return err
}
entries := kitsetup.PrepareProviderResolver(loaded).List()
if len(args) == 1 {
if items, ok := entries[args[0]]; ok {
for _, id := range sortedModelIDs(items) {
fmt.Println(id)
}
return nil
}
}
if len(args) == 0 {
providers := make([]string, 0, len(entries))
for provider := range entries {
providers = append(providers, provider)
}
sort.Strings(providers)
for _, provider := range providers {
for _, id := range sortedModelIDs(entries[provider]) {
fmt.Printf("%s/%s\n", provider, id)
}
}
}
}
if len(args) == 1 {
return printProvider(args[0])
}
Expand Down
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,8 @@ func runInteractiveModeBubbleTea(_ context.Context, deps runModeDeps) error {
SkillItems: snap.skillItems,
GetSkillItems: prov.getSkillItems,
ExtensionItems: snap.extensionItems,
GetModelEntries: prov.getModelEntries,
GetThinkingLevels: prov.getThinkingLevels,
GetExtensionItems: prov.getExtensionItems,
StartupExtensionMessages: snap.startupExtensionMessages,
GetWidgets: prov.getWidgets,
Expand Down
31 changes: 28 additions & 3 deletions cmd/run_mode.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ type startupSnapshot struct {
// uiProviders holds read-only getters that the TUI polls to render extension
// chrome and to refresh lists after hot-reload. None of these mutate state.
type uiProviders struct {
getModelEntries func() []ui.ModelEntry
getThinkingLevels func() []string
getPromptTemplates func() []*prompts.PromptTemplate
getSkillItems func() []ui.SkillItem
getExtensionItems func() []ui.ExtensionItem
Expand Down Expand Up @@ -281,8 +283,12 @@ func newRunApp(k *kit.Kit, cli *ui.CLI, mcpConfig *config.Config, modelName stri
if cli != nil {
usageTracker = cli.GetUsageTracker()
} else {
usageTracker = ui.CreateUsageTracker(viper.GetString("model"), k.IsOAuth())
ui.UpdateUsageTrackerForMode(usageTracker, viper.GetString("model"), k.CurrentModelMode(), k.IsOAuth())
if info := k.GetModelInfo(); info != nil {
usageTracker = ui.NewUsageTracker(info, kit.GetCurrentProvider(k.GetCurrentModel()), 80, k.IsOAuth())
}
if k.CurrentModelMode() != "" {
ui.UpdateUsageTrackerForMode(usageTracker, viper.GetString("model"), k.CurrentModelMode(), k.IsOAuth())
}
}
if usageTracker != nil {
appOpts.UsageTracker = usageTracker
Expand Down Expand Up @@ -500,6 +506,14 @@ func mcpResourceReaderForUI(k *kit.Kit) ui.MCPResourceReader {
// buildUIProviders wires the read-only getters the TUI polls.
func buildUIProviders(k *kit.Kit) uiProviders {
return uiProviders{
getThinkingLevels: k.SupportedThinkingLevels,
getModelEntries: func() []ui.ModelEntry {
entries := []ui.ModelEntry{}
for _, item := range k.GetAvailableModels() {
entries = append(entries, ui.ModelEntry{Provider: item.Provider, ModelID: item.ModelID, Name: item.Name, ContextLimit: item.ContextLimit, Reasoning: item.Reasoning, Available: modelEntryAvailable(k, item.Provider, item.ModelID), AvailabilityUnknown: kit.GetProviderInfo(item.Provider) == nil})
}
return entries
},
getPromptTemplates: func() []*prompts.PromptTemplate { return loadPromptTemplates(true) },
getSkillItems: func() []ui.SkillItem { return reloadSkillItems(k) },
// getExtensionItems re-collects the loaded extension list, used by the
Expand Down Expand Up @@ -573,7 +587,9 @@ func buildUIActions(k *kit.Kit, appInstance *app.App, usageTracker *ui.UsageTrac
// and m.modelName directly after setModel returns. Extension model
// changes instead use the deferred NotifyModelChanged notification.
// Update usage tracker with new model info for correct token counting.
ui.UpdateUsageTrackerForModel(usageTracker, modelString, k.IsOAuth())
if info := k.GetModelInfo(); info != nil && usageTracker != nil {
usageTracker.UpdateModelInfo(info, kit.GetCurrentProvider(modelString), k.IsOAuth())
}
return nil
},
// saveProviderAPIKey backs the /connect command. Keys go to the
Expand Down Expand Up @@ -717,3 +733,12 @@ func startContentWatcher(ctx context.Context, appInstance *app.App) func() {
go contentWatcher.Start(ctx)
return func() { _ = contentWatcher.Close() }
}

func modelEntryAvailable(k *kit.Kit, provider, model string) bool {
if kit.GetProviderInfo(provider) != nil {
return kit.ValidateEnvironment(provider, "") == nil
}
// Extension auth callbacks are not invoked from the UI event loop. Until
// asynchronous status refresh completes, only no-auth providers are enabled.
return k.CheckModelAvailable(provider + "/" + model)
}
41 changes: 41 additions & 0 deletions examples/extensions/fake-provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//go:build ignore

package main

import "kit/ext"

// Helpers precede registration because Yaegi does not reliably resolve later
// named helpers referenced from function literals.
func fakeCreate(req ext.ProviderCreateRequest) ext.ProviderCreateResult {
return ext.ProviderCreateResult{Runtime: ext.ProviderRuntime{
Stream: func(req ext.ProviderRequest, emit func(ext.ProviderEvent) bool) ext.ProviderResult {
select {
case <-req.Done:
return ext.ProviderResult{Error: &ext.ProviderError{Code: "canceled"}}
default:
}
if !emit(ext.ProviderEvent{Type: ext.ProviderTextStart, ID: "greeting"}) {
return ext.ProviderResult{}
}
if !emit(ext.ProviderEvent{Type: ext.ProviderTextDelta, ID: "greeting", Delta: "Hello from a fake provider."}) {
return ext.ProviderResult{}
}
if !emit(ext.ProviderEvent{Type: ext.ProviderTextEnd, ID: "greeting"}) {
return ext.ProviderResult{}
}
emit(ext.ProviderEvent{Type: ext.ProviderFinish, FinishReason: ext.ProviderFinishStop})
return ext.ProviderResult{}
},
Close: func() error { return nil },
}}
}

// Init declares a deterministic offline provider. No API key is required.
// kit --bare -e examples/extensions/fake-provider.go --model fake/echo --no-session --no-core-tools --quiet "hello"
func Init(api ext.API) {
api.RegisterProvider(ext.ProviderDef{
ID: "fake", Name: "Fake provider", Auth: ext.ProviderAuth{Mode: ext.ProviderAuthNone},
Models: []ext.ProviderModel{{ID: "echo", Name: "Deterministic greeting", Capabilities: ext.ProviderCapabilities{Streaming: true, Tools: true, InputModalities: []string{"text"}}}},
Create: fakeCreate,
})
}
52 changes: 52 additions & 0 deletions examples/extensions/fake-tool-provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//go:build ignore

package main

import "kit/ext"

func toolCreate(req ext.ProviderCreateRequest) ext.ProviderCreateResult {
return ext.ProviderCreateResult{Runtime: ext.ProviderRuntime{Stream: func(req ext.ProviderRequest, emit func(ext.ProviderEvent) bool) ext.ProviderResult {
// History, not a process-global counter, determines the next response.
resultSeen := false
for _, message := range req.Messages {
for _, part := range message.Parts {
if part.Type == ext.ProviderPartToolResult && part.CallID == "add-call" && part.Text == "5" && !part.IsError {
resultSeen = true
}
}
}
if resultSeen {
if !emit(ext.ProviderEvent{Type: ext.ProviderTextStart, ID: "answer"}) {
return ext.ProviderResult{}
}
if !emit(ext.ProviderEvent{Type: ext.ProviderTextDelta, ID: "answer", Delta: "5"}) {
return ext.ProviderResult{}
}
if !emit(ext.ProviderEvent{Type: ext.ProviderTextEnd, ID: "answer"}) {
return ext.ProviderResult{}
}
emit(ext.ProviderEvent{Type: ext.ProviderFinish, FinishReason: ext.ProviderFinishStop})
} else {
if !emit(ext.ProviderEvent{Type: ext.ProviderToolStart, ID: "add-call", ToolName: "add"}) {
return ext.ProviderResult{}
}
if !emit(ext.ProviderEvent{Type: ext.ProviderToolDelta, ID: "add-call", Delta: `{"a":2,`}) {
return ext.ProviderResult{}
}
if !emit(ext.ProviderEvent{Type: ext.ProviderToolDelta, ID: "add-call", Delta: `"b":3}`}) {
return ext.ProviderResult{}
}
if !emit(ext.ProviderEvent{Type: ext.ProviderToolEnd, ID: "add-call"}) {
return ext.ProviderResult{}
}
emit(ext.ProviderEvent{Type: ext.ProviderFinish, FinishReason: ext.ProviderFinishToolCalls})
}
return ext.ProviderResult{}
}}}
}

func Init(api ext.API) {
api.RegisterProvider(ext.ProviderDef{ID: "fake-tool", Name: "History-driven tool fixture", Create: toolCreate,
Models: []ext.ProviderModel{{ID: "add", Name: "Add", Capabilities: ext.ProviderCapabilities{Streaming: true, Tools: true}}},
})
}
14 changes: 12 additions & 2 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1474,6 +1474,7 @@ func (a *Agent) SetModel(ctx context.Context, config *models.ProviderConfig) err
a.modelConfig = config
a.providerErr = nil
if a.currentModel != nil {
a.currentModel.resolver.Store(config.Resolver)
a.currentModel.set(config.ModelString)
}

Expand Down Expand Up @@ -1577,7 +1578,10 @@ func (a *Agent) GetMaxTokens() int {
// If MCP tools are still loading in the background, Close waits for them
// to finish before closing connections to avoid resource leaks. The wait
// is bounded so a hung MCP transport can never block shutdown forever.
func (a *Agent) Close() error {
func (a *Agent) Close() error { return a.CloseContext(context.Background()) }

// CloseContext bounds cooperative provider cleanup by the caller's deadline.
func (a *Agent) CloseContext(ctx context.Context) error {
// Wait for background MCP loading to finish before closing connections.
if a.mcpReady != nil {
select {
Expand All @@ -1591,7 +1595,13 @@ func (a *Agent) Close() error {
toolErr = a.toolManager.Close()
}
if a.providerCloser != nil {
if err := a.providerCloser.Close(); err != nil && toolErr == nil {
var err error
if closer, ok := a.providerCloser.(interface{ CloseContext(context.Context) error }); ok {
err = closer.CloseContext(ctx)
} else {
err = a.providerCloser.Close()
}
if err != nil && toolErr == nil {
toolErr = err
}
}
Expand Down
8 changes: 7 additions & 1 deletion internal/agent/image_gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,14 @@ func imageUnsupportedText(resp fantasy.ToolResponse, model string) string {
// activeModel holds the model string of the active model. Tools read it while
// they run, possibly in parallel with SetModel, so it is atomic.
type activeModel struct {
v atomic.Pointer[string]
resolver atomic.Pointer[models.Resolver]
v atomic.Pointer[string]
}

func newActiveModel(cfg *models.ProviderConfig) *activeModel {
m := &activeModel{}
if cfg != nil {
m.resolver.Store(cfg.Resolver)
m.set(cfg.ModelString)
}
return m
Expand All @@ -87,6 +89,10 @@ func (m *activeModel) imageInput() (bool, string) {
if p == nil || *p == "" {
return true, ""
}
provider, _, _ := models.ParseModelString(*p)
if m.resolver.Load().Owns(provider) {
return true, *p
} // Preserve input for explicit adapter validation.
return modelImageInput(*p), *p
}

Expand Down
4 changes: 4 additions & 0 deletions internal/agent/retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ func retryableRequestError(err error) bool {
if err == nil || err == context.DeadlineExceeded || errors.Is(err, context.Canceled) {
return false
}
var extensionError interface{ ExtensionRetryable() bool }
if errors.As(err, &extensionError) {
return extensionError.ExtensionRetryable()
}
streamMessage := retryableStreamMessage(err.Error())
if pe, ok := errors.AsType[*fantasy.ProviderError](err); ok {
if pe.AuthError || pe.StatusCode == http.StatusUnauthorized || pe.StatusCode == http.StatusForbidden {
Expand Down
47 changes: 47 additions & 0 deletions internal/agent/stream_turn.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/rand"
"errors"
"fmt"
"slices"
"strings"
"sync"

Expand Down Expand Up @@ -391,6 +392,52 @@ func (t *streamTurn) onToolResult(tr fantasy.ToolResultContent) error {
}

func (t *streamTurn) onStepFinish(step fantasy.StepResult) error {
// Host-executed tool results do not inherit model metadata in the model
// library. Carry the matching extension origin so persistence keeps IDs.
if metadata := message.ExtensionOptions(fantasy.ProviderOptions(step.ProviderMetadata)); metadata != nil {
for i := range step.Messages {
positions := map[string]int{}
for index, id := range metadata.Order {
positions[id] = index
}
slices.SortStableFunc(step.Messages[i].Content, func(a, b fantasy.MessagePart) int {
left, right := message.ExtensionOptions(a.Options()), message.ExtensionOptions(b.Options())
if left == nil || right == nil {
return 0
}
return positions[left.BlockID] - positions[right.BlockID]
})
step.Messages[i].ProviderOptions = message.CloneOptions(step.Messages[i].ProviderOptions)
if step.Messages[i].ProviderOptions == nil {
step.Messages[i].ProviderOptions = fantasy.ProviderOptions{}
}
step.Messages[i].ProviderOptions["kit-extension"] = metadata
}
}
origins := map[string]*message.ExtensionMetadata{}
for _, msg := range step.Messages {
for _, part := range msg.Content {
if call, ok := part.(fantasy.ToolCallPart); ok {
if metadata := message.ExtensionOptions(call.ProviderOptions); metadata != nil {
origins[call.ToolCallID] = metadata
}
}
}
}
for i, msg := range step.Messages {
for j, part := range msg.Content {
if result, ok := part.(fantasy.ToolResultPart); ok {
if metadata := origins[result.ToolCallID]; metadata != nil {
result.ProviderOptions = message.CloneOptions(result.ProviderOptions)
if result.ProviderOptions == nil {
result.ProviderOptions = fantasy.ProviderOptions{}
}
result.ProviderOptions["kit-extension"] = &message.ExtensionMetadata{Provider: metadata.Provider}
step.Messages[i].Content[j] = result
}
}
}
}
// Accumulate messages from completed steps so they can be
// persisted even if a later step is cancelled.
t.completedStepMessages = append(t.completedStepMessages, step.Messages...)
Expand Down
Loading
Loading