Compare commits

...

7 Commits

Author SHA1 Message Date
Ed Zynda 06bf6d087a feat(models): resolve SDK default URLs for all registered providers
- Add sdkDefaultBaseURL map covering the 14 npm SDKs that ship a
  hard-coded baseURL (groq, cerebras, mistral, xai, perplexity,
  togetherai, deepinfra, cohere, v0, aihubmix, venice, merge-gateway,
  openrouter, vercel gateway), so providers whose models.dev entry
  omits the api field still auto-route correctly.
- Extend npmToWireProtocol so these thin OpenAI-compatible wrappers
  route through fantasy's openaicompat provider.
- Add resolveTemplatedAPIURL to substitute ${VAR} placeholders for
  cloudflare-workers-ai, databricks, snowflake-cortex from the env,
  with friendly errors that name the missing vars.
- Wire amazon-bedrock and azure-cognitive-services aliases into the
  existing native handlers; add createGoogleVertexProvider for the
  google-vertex case.
- Expose kit.ResolveProviderBaseURL in the public SDK so embedders
  can introspect the effective endpoint before instantiating a Kit.
- Refresh embedded_models.json from models.dev (5113 -> 5121 models;
  139 providers unchanged).
2026-06-07 14:06:05 +03:00
Ed Zynda fd960921ca refactor: address code audit findings across SDK, cmd, and internals
- Remove deprecated GenerateWithLoopAndStreaming and TreeManager
  AppendFantasyMessage / AddFantasyMessages / GetFantasyMessages to
  close the SDK leakage caused by the kit.TreeManager type alias
- Switch extensionAPI method signatures to local Extension* aliases so
  pkg.go.dev signatures no longer expose internal package names
- Bundle runNormalMode dependencies into a runModeDeps struct, shrinking
  the runNonInteractive and runInteractive call sites from 40+ positional
  args to (ctx, deps)
- Add generic subscribeTyped[E Event] helper and collapse ~30 typed OnXxx
  wrappers in pkg/kit/events.go onto it (public signatures unchanged)
- Extract setupBashPipes / interpretBashExit in internal/core/bash.go to
  deduplicate the buffered and streaming execution paths
- Extract resolveAutoRouteAPIKey and wrapProviderErr helpers in
  internal/models/providers.go and uniformly apply them across every
  createXxxProvider site
- Reimplement internal/extensions/watcher.go as a thin wrapper over the
  general-purpose internal/watcher.ContentWatcher, eliminating ~130 LOC
  of duplicated fsnotify logic while preserving the existing test API
- Add ctx.Err() pre-flight checks in executeRead / Write / Edit / Ls so
  cancellation actually short-circuits pure file-IO tools
2026-06-06 19:22:05 +03:00
Ed Zynda 0b651a8df9 build(deps): update dependencies except fantasy
- bump bubbletea v2.0.6 -> v2.0.7, ultraviolet, acp-go-sdk v0.13.0 -> v0.13.5
- bump indirect deps x/exp, charmtone, go-runewidth
- hold fantasy at v0.25.0 (v0.29.1 requires go 1.26.4)
- add no-op Logout method to acpserver.Agent for new acp.Agent interface
2026-06-04 15:48:07 +03:00
Ed Zynda 7315c1dea7 chore(models): update embedded model database from models.dev
- Refresh internal/models/embedded_models.json with latest data
- Add providers: alibaba-token-plan, anyapi, snowflake-cortex
- 139 providers, 5113 models total
2026-06-04 15:35:43 +03:00
Ed Zynda 0313fa03ad fix(ui): show pasted image previews in input and transcript (#48)
* fix(ui): show pasted image previews in input and transcript

The half-block thumbnail preview added in #47 rendered but was clipped
off the bottom of the screen, and submitted images showed only a text
badge in the conversation history.

- Mark the layout dirty when clipboardImageMsg / thumbnailReadyMsg reach
  the parent, so distributeHeight re-measures the now-taller input region
  instead of keeping a stale height that pushed the preview off-screen
- Render thumbnail previews in the transcript after a user message,
  appended as a verbatim ScrollList item (raw ANSI half-blocks would be
  mangled if folded into the word-wrapped user text block)
- Render transcript previews asynchronously via a tea.Cmd so decode +
  resample never blocks the Bubble Tea event loop
- Add regression tests covering the input layout recompute and the
  transcript preview flow

* fix(ui): anchor transcript image preview to its user message

- Insert the async thumbnail preview directly after the originating user
  message (tracked via anchorID) instead of appending, so a streamed
  assistant reply that lands first no longer pushes the preview out of place
- Make the layout regression test deterministic by forcing a truecolor
  profile, avoiding flakes on low-color CI terminals where the thumbnail
  would render empty
- Add tests for anchored insertion and the unknown-anchor append fallback
2026-06-04 15:30:47 +03:00
Ed Zynda d27022bcfb feat(ui): render half-block thumbnails for attached images (#47)
* feat(ui): render half-block thumbnails for attached images (#46)

- Add internal/ui/imagepreview package: Render() draws low-res
  thumbnails using Unicode half-blocks (▀) + truecolor/256-color SGR,
  which survives tmux/zellij (no graphics protocol)
- Cache a rendered thumbnail per pending clipboard image in the input
  component; render once at attach time, never per frame
- Fall back to the existing [N image(s) attached] text pill when the
  terminal lacks truecolor/256-color support
- Document Ctrl+V paste, Ctrl+U clear, and the preview in the docs
  site and README keyboard shortcuts

Fixes #46

* fix(ui): render image thumbnails off the event loop and cap size

- Render thumbnails asynchronously via a tea.Cmd instead of calling
  the decode + resample path synchronously inside Update(), which
  blocked the Bubble Tea event loop
- Add thumbnailReadyMsg + an imageGen generation counter so async
  results land on the correct pendingImages slot and stale renders
  after a clear/re-attach are discarded
- Guard imagepreview.Render against decompression bombs by checking
  DecodeConfig dimensions against a max before full decode

* fix(ui): skip image preview when input width is too small

- Return 0 from thumbCols when width <= 6 so a full-size thumbnail is
  no longer rendered for tiny or uninitialized (width 0) terminals;
  the caller falls back to the text pill
2026-06-04 14:36:39 +03:00
Ed Zynda ae722d520f fix(models): route auto-discovered providers by wire protocol (#41) (#43)
- replace npmToLLMProvider map with npmToWireProtocol (openai/anthropic/google)
- add createAutoRoutedGoogleProvider so @ai-sdk/google proxies work
  (fixes opencode/gemini-* failing with "no LLM provider mapping")
- strip the genai-injected v1beta segment for proxies whose base URL
  already carries a version (e.g. opencode's /zen/v1)
- preserve openai-compat fallback and clearer error for unroutable providers
- document auto-routing in README and providers docs; update CreateProvider godoc
- add regression tests for wire routing and version-path rewriting

Fixes #41
2026-06-02 15:21:48 +03:00
31 changed files with 2355 additions and 604 deletions
+27
View File
@@ -509,6 +509,8 @@ During an interactive session, use these slash commands:
| Shortcut | Description |
|----------|-------------|
| `Ctrl+V` | Paste an image from the clipboard — shows an inline low-res thumbnail preview (tmux/zellij-safe) |
| `Ctrl+U` | Clear all pending image attachments |
| `Ctrl+X e` | Open `$VISUAL`/`$EDITOR` to compose or edit your prompt |
| `Ctrl+X s` | Steer — inject a system-level instruction mid-turn |
| `ESC ESC` | Cancel the current operation (tool call or streaming) |
@@ -972,6 +974,31 @@ This automatically defaults to `custom/custom` without needing to specify a mode
- Reasoning and temperature support
- Optional `CUSTOM_API_KEY` environment variable or `--provider-api-key` flag
### Auto-routed Providers
Any provider in the [models.dev](https://models.dev) database can be used as
`provider/model` without a dedicated native integration. Kit auto-routes the
request through the matching **wire protocol** based on the provider's npm package
(or per-model override), using its `api` URL as the base:
| npm package | Wire protocol |
|-------------|---------------|
| `@ai-sdk/openai` | OpenAI (Responses API) |
| `@ai-sdk/openai-compatible` | OpenAI (chat completions) |
| `@ai-sdk/anthropic` | Anthropic |
| `@ai-sdk/google` | Google Gemini |
Providers with an `api` URL but an unrecognized npm package fall back to the
OpenAI-compatible wire. Because routing follows the wire protocol, aggregator/proxy
providers work across all of their models — including Claude, GPT, *and* Gemini
routes:
```bash
kit --model opencode/claude-haiku-4-5 "Hello" # → Anthropic wire
kit --model opencode/gpt-5 "Hello" # → OpenAI wire
kit --model opencode/gemini-3.5-flash "Hello" # → Google wire
```
### Model String Format
```bash
+152 -45
View File
@@ -1262,9 +1262,57 @@ func runNormalMode(ctx context.Context) error {
}
}
// Bundle all the shared dependencies into a single struct that both
// run-mode entry points consume. This keeps the dispatch site and the
// function signatures readable.
deps := runModeDeps{
appInstance: appInstance,
cli: cli,
modelName: modelName,
providerName: parsedProvider,
loadingMessage: kitInstance.GetLoadingMessage(),
serverNames: serverNames,
toolNames: toolNames,
mcpToolCount: mcpToolCount,
extensionToolCount: extensionToolCount,
usageTracker: usageTracker,
extCommands: extCommands,
promptTemplates: promptTemplates,
contextPaths: contextPaths,
skillItems: skillItems,
extensionItems: extensionItems,
getPromptTemplates: getPromptTemplates,
getSkillItems: getSkillItems,
getExtensionItems: getExtensionItems,
getToolNames: getToolNames,
getMCPToolCount: getMCPToolCount,
mcpPrompts: mcpPrompts,
getMCPPrompts: getMCPPrompts,
expandMCPPrompt: expandMCPPrompt,
getWidgets: getWidgets,
getHeader: getHeader,
getFooter: getFooter,
getToolRenderer: getToolRenderer,
getEditorInterceptor: getEditorInterceptor,
getUIVisibility: getUIVisibility,
getStatusBarEntries: getStatusBarEntries,
emitBeforeFork: emitBeforeFork,
emitBeforeSessionSwitch: emitBeforeSessionSwitch,
getGlobalShortcuts: getGlobalShortcuts,
getExtensionCommands: getExtensionCommands,
setModel: setModelForUI,
emitModelChange: emitModelChangeForUI,
isReasoningModel: kitInstance.IsReasoningModel(),
thinkingLevel: kitInstance.GetThinkingLevel(),
setThinkingLevel: setThinkingLevelForUI,
switchSession: switchSessionForUI,
reloadExtensions: reloadExtensionsForUI,
startupExtensionMessages: startupExtensionMessages,
}
// Check if running in non-interactive mode
if positionalPrompt != "" {
return runNonInteractiveModeApp(ctx, appInstance, cli, positionalPrompt, quietFlag, jsonFlag, noExitFlag, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, extensionItems, getPromptTemplates, getSkillItems, getExtensionItems, getToolNames, getMCPToolCount, mcpPrompts, getMCPPrompts, expandMCPPrompt, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI)
return runNonInteractiveModeApp(ctx, deps, positionalPrompt, quietFlag, jsonFlag, noExitFlag)
}
// Quiet mode is not allowed in interactive mode
@@ -1272,7 +1320,7 @@ func runNormalMode(ctx context.Context) error {
return fmt.Errorf("--quiet requires a prompt")
}
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, parsedProvider, kitInstance.GetLoadingMessage(), serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, extensionItems, getPromptTemplates, getSkillItems, getExtensionItems, getToolNames, getMCPToolCount, mcpPrompts, getMCPPrompts, expandMCPPrompt, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModelForUI, emitModelChangeForUI, kitInstance.IsReasoningModel(), kitInstance.GetThinkingLevel(), setThinkingLevelForUI, switchSessionForUI, reloadExtensionsForUI, startupExtensionMessages)
return runInteractiveModeBubbleTea(ctx, deps)
}
// runNonInteractiveModeApp executes a single prompt via the app layer and exits,
@@ -1285,7 +1333,10 @@ func runNormalMode(ctx context.Context) error {
//
// When --no-exit is set, after the prompt completes the interactive BubbleTea
// TUI is started so the user can continue the conversation.
func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui.CLI, prompt string, quiet, jsonOutput, noExit bool, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, extensionItems []ui.ExtensionItem, getPromptTemplates func() []*prompts.PromptTemplate, getSkillItems func() []ui.SkillItem, getExtensionItems func() []ui.ExtensionItem, getToolNames func() []string, getMCPToolCount func() int, mcpPrompts []ui.MCPPromptInfo, getMCPPrompts func() []ui.MCPPromptInfo, expandMCPPrompt func(string, string, map[string]string) (*ui.MCPPromptExpandResult, error), getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error) error {
func runNonInteractiveModeApp(ctx context.Context, deps runModeDeps, prompt string, quiet, jsonOutput, noExit bool) error {
appInstance := deps.appInstance
cli := deps.cli
modelName := deps.modelName
// Expand @file references in the prompt before sending to the agent.
// Text files are XML-inlined; binary files are extracted as multimodal parts.
var fileParts []kit.LLMFilePart
@@ -1346,12 +1397,67 @@ func runNonInteractiveModeApp(ctx context.Context, appInstance *app.App, cli *ui
// If --no-exit was requested, hand off to the interactive TUI.
if noExit {
return runInteractiveModeBubbleTea(ctx, appInstance, modelName, providerName, loadingMessage, serverNames, toolNames, mcpToolCount, extensionToolCount, usageTracker, extCommands, promptTemplates, contextPaths, skillItems, extensionItems, getPromptTemplates, getSkillItems, getExtensionItems, getToolNames, getMCPToolCount, mcpPrompts, getMCPPrompts, expandMCPPrompt, getWidgets, getHeader, getFooter, getToolRenderer, getEditorInterceptor, getUIVisibility, getStatusBarEntries, emitBeforeFork, emitBeforeSessionSwitch, getGlobalShortcuts, getExtensionCommands, setModel, emitModelChange, isReasoningModel, thinkingLevel, setThinkingLevel, switchSession, reloadExtensions, nil)
// Drop the cli (interactive mode doesn't use it) and clear the
// interactive-only fields explicitly; deps carries everything else.
interactive := deps
interactive.cli = nil
interactive.startupExtensionMessages = nil
return runInteractiveModeBubbleTea(ctx, interactive)
}
return nil
}
// runModeDeps bundles the shared dependencies that runNormalMode wires up
// once and threads to both runNonInteractiveModeApp and
// runInteractiveModeBubbleTea. Grouping them into a single struct keeps the
// call sites and signatures readable and makes it trivial to add a new
// provider callback without touching every call chain.
type runModeDeps struct {
appInstance *app.App
cli *ui.CLI // non-interactive only
modelName string
providerName string
loadingMessage string
serverNames []string
toolNames []string
mcpToolCount int
extensionToolCount int
usageTracker *ui.UsageTracker
extCommands []commands.ExtensionCommand
promptTemplates []*prompts.PromptTemplate
contextPaths []string
skillItems []ui.SkillItem
extensionItems []ui.ExtensionItem
getPromptTemplates func() []*prompts.PromptTemplate
getSkillItems func() []ui.SkillItem
getExtensionItems func() []ui.ExtensionItem
getToolNames func() []string
getMCPToolCount func() int
mcpPrompts []ui.MCPPromptInfo
getMCPPrompts func() []ui.MCPPromptInfo
expandMCPPrompt func(string, string, map[string]string) (*ui.MCPPromptExpandResult, error)
getWidgets func(string) []ui.WidgetData
getHeader func() *ui.WidgetData
getFooter func() *ui.WidgetData
getToolRenderer func(string) *ui.ToolRendererData
getEditorInterceptor func() *ui.EditorInterceptor
getUIVisibility func() *ui.UIVisibility
getStatusBarEntries func() []ui.StatusBarEntryData
emitBeforeFork func(string, bool, string) (bool, string)
emitBeforeSessionSwitch func(string) (bool, string)
getGlobalShortcuts func() map[string]func()
getExtensionCommands func() []commands.ExtensionCommand
setModel func(string) error
emitModelChange func(string, string, string)
isReasoningModel bool
thinkingLevel string
setThinkingLevel func(string) error
switchSession func(string) error
reloadExtensions func() error
startupExtensionMessages []string // interactive only
}
// ---------------------------------------------------------------------------
// JSON output helpers (--json mode)
// ---------------------------------------------------------------------------
@@ -1444,7 +1550,8 @@ func writeJSONError(err error) {
// 4. Calls program.Run() which blocks until the user quits (Ctrl+C or /quit).
//
// SetupCLI is not used for interactive mode; the TUI (AppModel) handles its own rendering.
func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelName, providerName, loadingMessage string, serverNames, toolNames []string, mcpToolCount, extensionToolCount int, usageTracker *ui.UsageTracker, extCommands []commands.ExtensionCommand, promptTemplates []*prompts.PromptTemplate, contextPaths []string, skillItems []ui.SkillItem, extensionItems []ui.ExtensionItem, getPromptTemplates func() []*prompts.PromptTemplate, getSkillItems func() []ui.SkillItem, getExtensionItems func() []ui.ExtensionItem, getToolNames func() []string, getMCPToolCount func() int, mcpPrompts []ui.MCPPromptInfo, getMCPPrompts func() []ui.MCPPromptInfo, expandMCPPrompt func(string, string, map[string]string) (*ui.MCPPromptExpandResult, error), getWidgets func(string) []ui.WidgetData, getHeader, getFooter func() *ui.WidgetData, getToolRenderer func(string) *ui.ToolRendererData, getEditorInterceptor func() *ui.EditorInterceptor, getUIVisibility func() *ui.UIVisibility, getStatusBarEntries func() []ui.StatusBarEntryData, emitBeforeFork func(string, bool, string) (bool, string), emitBeforeSessionSwitch func(string) (bool, string), getGlobalShortcuts func() map[string]func(), getExtensionCommands func() []commands.ExtensionCommand, setModel func(string) error, emitModelChange func(string, string, string), isReasoningModel bool, thinkingLevel string, setThinkingLevel func(string) error, switchSession func(string) error, reloadExtensions func() error, startupExtensionMessages []string) error {
func runInteractiveModeBubbleTea(_ context.Context, deps runModeDeps) error {
appInstance := deps.appInstance
// Redirect all log output (stdlib and charm) to a file so that log
// messages don't write to stderr and corrupt the TUI. Bubble Tea
// captures stdout for rendering; any stray stderr output from
@@ -1467,49 +1574,49 @@ func runInteractiveModeBubbleTea(_ context.Context, appInstance *app.App, modelN
cwd, _ := os.Getwd()
appModel := ui.NewAppModel(appInstance, ui.AppModelOptions{
ModelName: modelName,
ProviderName: providerName,
LoadingMessage: loadingMessage,
ModelName: deps.modelName,
ProviderName: deps.providerName,
LoadingMessage: deps.loadingMessage,
Cwd: cwd,
Width: termWidth,
Height: termHeight,
ServerNames: serverNames,
ToolNames: toolNames,
GetToolNames: getToolNames,
GetMCPToolCount: getMCPToolCount,
MCPToolCount: mcpToolCount,
ExtensionToolCount: extensionToolCount,
UsageTracker: usageTracker,
ExtensionCommands: extCommands,
PromptTemplates: promptTemplates,
GetPromptTemplates: getPromptTemplates,
MCPPrompts: mcpPrompts,
GetMCPPrompts: getMCPPrompts,
ExpandMCPPrompt: expandMCPPrompt,
ContextPaths: contextPaths,
SkillItems: skillItems,
GetSkillItems: getSkillItems,
ExtensionItems: extensionItems,
GetExtensionItems: getExtensionItems,
StartupExtensionMessages: startupExtensionMessages,
GetWidgets: getWidgets,
GetHeader: getHeader,
GetFooter: getFooter,
GetToolRenderer: getToolRenderer,
GetEditorInterceptor: getEditorInterceptor,
GetUIVisibility: getUIVisibility,
GetStatusBarEntries: getStatusBarEntries,
EmitBeforeFork: emitBeforeFork,
EmitBeforeSessionSwitch: emitBeforeSessionSwitch,
GetGlobalShortcuts: getGlobalShortcuts,
GetExtensionCommands: getExtensionCommands,
SetModel: setModel,
EmitModelChange: emitModelChange,
ThinkingLevel: thinkingLevel,
IsReasoningModel: isReasoningModel,
SetThinkingLevel: setThinkingLevel,
SwitchSession: switchSession,
ReloadExtensions: reloadExtensions,
ServerNames: deps.serverNames,
ToolNames: deps.toolNames,
GetToolNames: deps.getToolNames,
GetMCPToolCount: deps.getMCPToolCount,
MCPToolCount: deps.mcpToolCount,
ExtensionToolCount: deps.extensionToolCount,
UsageTracker: deps.usageTracker,
ExtensionCommands: deps.extCommands,
PromptTemplates: deps.promptTemplates,
GetPromptTemplates: deps.getPromptTemplates,
MCPPrompts: deps.mcpPrompts,
GetMCPPrompts: deps.getMCPPrompts,
ExpandMCPPrompt: deps.expandMCPPrompt,
ContextPaths: deps.contextPaths,
SkillItems: deps.skillItems,
GetSkillItems: deps.getSkillItems,
ExtensionItems: deps.extensionItems,
GetExtensionItems: deps.getExtensionItems,
StartupExtensionMessages: deps.startupExtensionMessages,
GetWidgets: deps.getWidgets,
GetHeader: deps.getHeader,
GetFooter: deps.getFooter,
GetToolRenderer: deps.getToolRenderer,
GetEditorInterceptor: deps.getEditorInterceptor,
GetUIVisibility: deps.getUIVisibility,
GetStatusBarEntries: deps.getStatusBarEntries,
EmitBeforeFork: deps.emitBeforeFork,
EmitBeforeSessionSwitch: deps.emitBeforeSessionSwitch,
GetGlobalShortcuts: deps.getGlobalShortcuts,
GetExtensionCommands: deps.getExtensionCommands,
SetModel: deps.setModel,
EmitModelChange: deps.emitModelChange,
ThinkingLevel: deps.thinkingLevel,
IsReasoningModel: deps.isReasoningModel,
SetThinkingLevel: deps.setThinkingLevel,
SwitchSession: deps.switchSession,
ReloadExtensions: deps.reloadExtensions,
ShowSessionPicker: resumeFlag,
GetMCPResources: mcpGetResources,
MCPResourceReader: mcpResourceReader,
+8 -7
View File
@@ -4,21 +4,22 @@ go 1.26.3
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.6
charm.land/bubbletea/v2 v2.0.7
charm.land/fantasy v0.25.0
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.3
github.com/alecthomas/chroma/v2 v2.26.1
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/fang v1.0.0
github.com/charmbracelet/log v1.0.0
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f
github.com/charmbracelet/x/editor v0.2.0
github.com/clipperhouse/displaywidth v0.11.0
github.com/clipperhouse/uax29/v2 v2.7.0
github.com/coder/acp-go-sdk v0.13.0
github.com/coder/acp-go-sdk v0.13.5
github.com/fsnotify/fsnotify v1.10.1
github.com/indaco/herald v0.13.0
github.com/indaco/herald-md v0.3.0
@@ -26,6 +27,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/traefik/yaegi v0.16.1
golang.org/x/image v0.41.0
golang.org/x/term v0.43.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -55,11 +57,10 @@ require (
github.com/catppuccin/go v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260527151214-009e6338d40d // indirect
github.com/charmbracelet/x/exp/strings v0.1.0 // indirect
@@ -113,7 +114,7 @@ require (
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7 // indirect
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
@@ -132,7 +133,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
+87 -12
View File
@@ -1,13 +1,15 @@
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo=
charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g=
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
charm.land/fantasy v0.25.0 h1:oXOWY1ivmTSnhYGzAolscF8zKtavWZyBWv0LHRSwN5Q=
charm.land/fantasy v0.25.0/go.mod h1:8QrWUzIcKwZQP+aAnC9vLu3iID6hu9/Jt+rPMiieBkc=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
charm.land/x/vcr v0.1.1/go.mod h1:eByq2gqzWvcct/8XE2XO5KznoWEBiXH56+y2gphbltM=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
@@ -16,6 +18,11 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
cloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA=
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
cloud.google.com/go/translate v1.10.3/go.mod h1:GW0vC1qvPtd3pgtypCv4k4U8B7EdgK9/QEF2aJEUovs=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
@@ -24,14 +31,20 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6Xu
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Rhymond/go-money v1.0.15/go.mod h1:iHvCuIvitxu2JIlAlhF0g9jHqjRSr+rpdOs7Omqlupg=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/ardanlabs/jinja v1.2.0/go.mod h1:aXXzlJfjA+T3XNKA/YT5ZtDq2VJxt5a5siZ8cl9B35Q=
github.com/ardanlabs/kronk v1.25.2/go.mod h1:b5Gg4jDqvHDklkeHNB8+7treZRxUiCFsV65zphrTloY=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.41.8 h1:sRs7nG6/RiEBZ/K5UO2sNw0w40U02Nmz1VtARloTZXk=
@@ -48,12 +61,16 @@ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24 h1:u6kJU2i0va1AgtJsH
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.24/go.mod h1:7GY+xLcXOFUpCkNwDReft9qOAVg54A4/AnjHIU7sSAY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24 h1:Xhbcf3KugX6vX7SDyUK205Oicyfg7EGuvoVNyP5L6DM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.24/go.mod h1:rwDgb2HNOGZsnTHylOUedM7Vnl+bCfnXDqUNPsFWYfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25 h1:54CTMmlJ71Rk2dYvM9qZOob+39wjlVja2zDLxCu69Ew=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.25/go.mod h1:BZaHqxsS9vN1fvV5EfEl0OBLOk5+AajWsMu6MjqnZB4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24 h1:CQW2FTrflfoslYWLf3fv7vG28Q219+v8YJS5QTQb2+Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.24/go.mod h1:Xfx13T+u3nH6EEzgl9fBSO6nDRmze1FvnZNYkctQ2zw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.0 h1:yQo3eZ5qFaL1sJWqs1nL6j3yPHA2/R7c6tQ4T+0IO10=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.0/go.mod h1:3Zzou41Qt/ueXfIzHvTEjDNuR5IjCUBVF01SNhrt1e8=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.18 h1:ApLTFdAZfDhZSiY5uskwECKHkSNNF83y2Ru2r7SezWA=
@@ -68,8 +85,14 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab h1:J7XQLgl9sefgTnTGrmX3xqvp5o6MCiBzEjGv5igAlc4=
@@ -86,8 +109,8 @@ github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdR
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 h1:BW/sZtyd1JyYy0h5adMm3tzpNyL857LWjuTRET6OhpY=
github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266/go.mod h1:1DahUaExbUZx/jD+FNT2PKP4L9rLE5+ZBRuI8mZjd/E=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek=
github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f h1:vKsPSlO4g4jKfJ9enESgNZ45BkbHngTIq3UxNOzic74=
github.com/charmbracelet/ultraviolet v0.0.0-20260601155805-6cf7526a1b3f/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
@@ -98,8 +121,8 @@ github.com/charmbracelet/x/editor v0.2.0 h1:7XLUKtaRaB8jN7bWU2p2UChiySyaAuIfYiIR
github.com/charmbracelet/x/editor v0.2.0/go.mod h1:p3oQ28TSL3YPd+GKJ1fHWcp+7bVGpedHpXmo0D6t1dY=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d h1:sMilwx1YIYTrQva6jsB522AoRYAerNaDIKP4ZPtUq0A=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260527151214-009e6338d40d/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a h1:aVvnksCVgxB2igk7jERL9ARIkbDXccp1gXCFqhGlamQ=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20260602025833-85a30b5e440a/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
@@ -120,12 +143,13 @@ github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcO
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/coder/acp-go-sdk v0.13.0 h1:IAKBDIbe/iBfKAGikeIndzb8fowt4ioD+gCtSU4HwMA=
github.com/coder/acp-go-sdk v0.13.0/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk=
github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
@@ -137,11 +161,16 @@ github.com/dlclark/regexp2/v2 v2.1.1 h1:LCUGyd9Wf+r+VVOl8Ny38JTpWJcAsdVnCIuhhtth
github.com/dlclark/regexp2/v2 v2.1.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dromara/carbon/v2 v2.6.16/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/eliben/go-sentencepiece v0.6.0/go.mod h1:nNYk4aMzgBoI6QFp4LUG8Eu1uO9fHD9L5ZEre93o9+c=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -150,6 +179,7 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686 h1:NZBJxCpbHS1gzS6xAmyxbJznosZIIPk9IB42v62UvKA=
github.com/go-json-experiment/json v0.0.0-20260520185125-572e7c383686/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
@@ -165,12 +195,17 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -181,14 +216,23 @@ github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hashicorp/aws-sdk-go-base/v2 v2.0.0-beta.72/go.mod h1:Vn+BBgKQHVQYdVQ4NZDICE1Brb+JfaONyDHr3q07oQc=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-getter v1.8.6/go.mod h1:nVH12eOV2P58dIiL3rsU6Fh3wLeJEKBOJzhMmzlSWoo=
github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hybridgroup/yzma v1.13.0/go.mod h1:zrzMgv/KVQz23+s6l16b+vJ+9uJVBdWtGcGkwRTMeiQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/indaco/herald v0.13.0 h1:+xVG9Fx5NpuWhwku/9IlRL6I009NnX4VUGKvlZHTRxU=
github.com/indaco/herald v0.13.0/go.mod h1:T5g1+XLYvpjouhzAGHnAHDCKizhESkoV6+QPZ3DhgWA=
github.com/indaco/herald-md v0.3.0 h1:hN1cKyrexPPM9PeHBsKuaWvIizSi/iYvM9yzRgtdb8M=
github.com/indaco/herald-md v0.3.0/go.mod h1:RUHVaDSG45ymJjKyxpDwBocLXrZo93FB4OeYMsw9B9s=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jupiterrider/ffi v0.7.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c=
github.com/kaptinlin/go-i18n v0.4.5 h1:9tIlo5A0RXth+yZJO2MG7Bhpu/X9PlzQnGz/qyYWNoY=
github.com/kaptinlin/go-i18n v0.4.5/go.mod h1:mU/7BH4molY5lGZYBwBRKAaiJ70dWRHuqmQ0/pFLGno=
github.com/kaptinlin/jsonpointer v0.4.25 h1:iJ197e8n+WwqaqBsa53FqG3rPJCg5oijyFXEXNWWC3E=
@@ -197,6 +241,7 @@ github.com/kaptinlin/jsonschema v0.7.13 h1:kahVXTy/rURL0XJjyQ9WELm59wEmXi6IY0TWs
github.com/kaptinlin/jsonschema v0.7.13/go.mod h1:Uh0aUBusnhXDCEXJ2oimL/hx7YTo7F+sKniE+tM0ERc=
github.com/kaptinlin/messageformat-go v0.6.0 h1:D6jiXFsKW4/JG2CMddv/F6Rev9KVbCRKEzzV5QOAcpc=
github.com/kaptinlin/messageformat-go v0.6.0/go.mod h1:NKjwS6e9u7DRhAK+vydjDDwJ7UbdHhYjk/yk2WPuZPs=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -205,12 +250,14 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0=
github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -225,6 +272,7 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -233,6 +281,10 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -240,8 +292,10 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@@ -253,6 +307,7 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -270,20 +325,27 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E=
github.com/traefik/yaegi v0.16.1/go.mod h1:4eVhbPb3LnD2VigQjhYbEJ69vDRFdT2HQNrXx8eEwUY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
@@ -292,12 +354,18 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7 h1:cHpkPjp4TILjdZxz/O4ykwCpeS+dDqNuDGse4zgQDCk=
golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
@@ -312,16 +380,21 @@ golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I=
google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genai v1.58.0 h1:MNA3ZkRyr7MnRwZ9RNZ60p4+UMKV3yYRw6pyHq4pp0U=
google.golang.org/genai v1.58.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348 h1:JjVGDZYWkJWZcxveJGzfkXC5myDVWAd4dZdgbzrDUv8=
google.golang.org/genproto v0.0.0-20260504160031-60b97b32f348/go.mod h1:95PqD4xM+AdOcBGsmgfaofXsiA37uXDtDufVbntT3TU=
google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA=
google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8=
google.golang.org/genproto/googleapis/bytestream v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:6TABGosqSqU2l1+fJ3jdvOYPPVryeKybxYF0cCZkTBE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
@@ -331,6 +404,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/dnaeon/go-vcr.v4 v4.0.6-0.20251110073552-01de4eb40290/go.mod h1:sbq5oMEcM4PXngbcNbHhzfCP9OdZodLhrbRYoyg09HY=
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+6
View File
@@ -61,6 +61,12 @@ func (a *Agent) Authenticate(_ context.Context, _ acp.AuthenticateRequest) (acp.
return acp.AuthenticateResponse{}, nil
}
// Logout handles logout requests. Kit doesn't require auth for local stdio
// usage, so this is a no-op.
func (a *Agent) Logout(_ context.Context, _ acp.LogoutRequest) (acp.LogoutResponse, error) {
return acp.LogoutResponse{}, nil
}
// Initialize negotiates capabilities with the ACP client.
func (a *Agent) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) {
log.Debug("acp: initialize", "protocol_version", params.ProtocolVersion)
+3 -41
View File
@@ -169,9 +169,9 @@ type RetryHandler func(attempt int, err error)
type PrepareStepHandler func(stepNumber int, messages []fantasy.Message) []fantasy.Message
// GenerateCallbacks consolidates all callback functions for
// GenerateWithLoopAndStreaming into a single struct. This replaces the previous
// 16+ positional callback parameters, making it easier to add new callbacks
// without breaking existing callers (new fields default to nil).
// GenerateWithCallbacks into a single struct, replacing what was previously
// 16+ positional callback parameters. New fields default to nil, so adding
// new callbacks does not break existing callers.
type GenerateCallbacks struct {
OnToolCall ToolCallHandler
OnToolExecution ToolExecutionHandler
@@ -522,44 +522,6 @@ func (a *Agent) GenerateWithLoop(ctx context.Context, messages []fantasy.Message
})
}
// GenerateWithLoopAndStreaming processes messages using the agent with streaming and callbacks.
// The agent handles the tool call loop internally.
//
// Deprecated: Use GenerateWithCallbacks instead, which takes a GenerateCallbacks
// struct and is easier to extend with new callbacks.
func (a *Agent) GenerateWithLoopAndStreaming(ctx context.Context, messages []fantasy.Message,
onToolCall ToolCallHandler, onToolExecution ToolExecutionHandler, onToolResult ToolResultHandler,
onResponse ResponseHandler, onToolCallContent ToolCallContentHandler,
onStreamingResponse StreamingResponseHandler,
onReasoningDelta ReasoningDeltaHandler,
onReasoningComplete ReasoningCompleteHandler,
onToolOutput ToolOutputHandler,
onStepMessages StepMessagesHandler,
onStepUsage StepUsageHandler,
onPasswordPrompt PasswordPromptHandler,
onToolCallStart ToolCallStartHandler,
onToolCallDelta ToolCallDeltaHandler,
onToolCallEnd ToolCallEndHandler,
) (*GenerateWithLoopResult, error) {
return a.GenerateWithCallbacks(ctx, messages, GenerateCallbacks{
OnToolCall: onToolCall,
OnToolExecution: onToolExecution,
OnToolResult: onToolResult,
OnResponse: onResponse,
OnToolCallContent: onToolCallContent,
OnStreamingResponse: onStreamingResponse,
OnReasoningDelta: onReasoningDelta,
OnReasoningComplete: onReasoningComplete,
OnToolOutput: onToolOutput,
OnStepMessages: onStepMessages,
OnStepUsage: onStepUsage,
OnPasswordPrompt: onPasswordPrompt,
OnToolCallStart: onToolCallStart,
OnToolCallDelta: onToolCallDelta,
OnToolCallEnd: onToolCallEnd,
})
}
// GenerateWithCallbacks processes messages using the agent with streaming and callbacks.
// The agent handles the tool call loop internally. We map the rich callback system
// to kit's existing callback interface for UI integration.
+57 -63
View File
@@ -249,34 +249,37 @@ func executeBash(ctx context.Context, call fantasy.ToolCall, workDir string) (fa
return executeBashBuffered(cmdCtx, call, cmd, sudoPassword)
}
// executeBashBuffered collects all output before returning (original behavior).
// It uses explicit pipes (not cmd.Stdout) so that cmd.WaitDelay can forcibly
// close them when grandchild processes hold pipe handles open after the
// direct child exits.
func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, sudoPassword string) (fantasy.ToolResponse, error) {
// setupBashPipes opens stdout/stderr pipes (plus an optional sudo stdin),
// starts the command, and asynchronously writes the sudo password if any.
// Returns the readers ready for the caller to consume. If setup fails,
// errResp is non-nil and the readers must not be used; the caller should
// return the response directly.
func setupBashPipes(cmd *exec.Cmd, sudoPassword string) (stdout, stderr io.Reader, errResp *fantasy.ToolResponse) {
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
r := fantasy.NewTextErrorResponse("failed to create stdout pipe")
return nil, nil, &r
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
r := fantasy.NewTextErrorResponse("failed to create stderr pipe")
return nil, nil, &r
}
// If we have a sudo password, create a stdin pipe and write the password
var stdinPipe io.WriteCloser
if sudoPassword != "" {
stdinPipe, err = cmd.StdinPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stdin pipe"), nil
r := fantasy.NewTextErrorResponse("failed to create stdin pipe")
return nil, nil, &r
}
}
if err := cmd.Start(); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
r := fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err))
return nil, nil, &r
}
// Write password to stdin if needed, then close stdin
if sudoPassword != "" && stdinPipe != nil {
go func() {
defer func() { _ = stdinPipe.Close() }()
@@ -284,19 +287,49 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
}()
}
return stdoutPipe, stderrPipe, nil
}
// interpretBashExit decodes cmd.Wait()'s error into an exit code, mapping
// context-deadline-exceeded to a friendly "command timed out" response.
// errResp is non-nil only when the caller should short-circuit and return
// it directly (e.g. timeout).
func interpretBashExit(waitErr error, cmdCtx context.Context) (exitCode int, errResp *fantasy.ToolResponse) {
if waitErr == nil {
return 0, nil
}
if exitErr, ok := waitErr.(*exec.ExitError); ok {
return exitErr.ExitCode(), nil
}
if cmdCtx.Err() == context.DeadlineExceeded {
r := fantasy.NewTextErrorResponse("command timed out")
return 0, &r
}
return 0, nil
}
// executeBashBuffered collects all output before returning (original behavior).
// It uses explicit pipes (not cmd.Stdout) so that cmd.WaitDelay can forcibly
// close them when grandchild processes hold pipe handles open after the
// direct child exits.
func executeBashBuffered(cmdCtx context.Context, _ fantasy.ToolCall, cmd *exec.Cmd, sudoPassword string) (fantasy.ToolResponse, error) {
stdoutPipe, stderrPipe, errResp := setupBashPipes(cmd, sudoPassword)
if errResp != nil {
return *errResp, nil
}
// Read pipes concurrently
var wg sync.WaitGroup
var stdout, stderr strings.Builder
var stdoutErr, stderrErr error
wg.Add(2)
go func() {
defer wg.Done()
_, stdoutErr = io.Copy(&stdout, stdoutPipe)
_, _ = io.Copy(&stdout, stdoutPipe)
}()
go func() {
defer wg.Done()
_, stderrErr = io.Copy(&stderr, stderrPipe)
_, _ = io.Copy(&stderr, stderrPipe)
}()
// Wait for the process to exit first. cmd.WaitDelay ensures that if
@@ -307,18 +340,9 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
// Wait for pipe readers to finish draining.
wg.Wait()
// Ignore pipe read errors caused by WaitDelay force-closing —
// we still have whatever was read before the close.
_ = stdoutErr
_ = stderrErr
exitCode := 0
if waitErr != nil {
if exitErr, ok := waitErr.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else if cmdCtx.Err() == context.DeadlineExceeded {
return fantasy.NewTextErrorResponse("command timed out"), nil
}
exitCode, errResp := interpretBashExit(waitErr, cmdCtx)
if errResp != nil {
return *errResp, nil
}
return buildBashResponse(stdout.String(), stderr.String(), exitCode)
@@ -326,35 +350,9 @@ func executeBashBuffered(cmdCtx context.Context, call fantasy.ToolCall, cmd *exe
// executeBashStreaming streams output as it arrives via the callback.
func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *exec.Cmd, outputCallback ToolOutputCallback, sudoPassword string) (fantasy.ToolResponse, error) {
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stdout pipe"), nil
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stderr pipe"), nil
}
// If we have a sudo password, create a stdin pipe
var stdinPipe io.WriteCloser
if sudoPassword != "" {
stdinPipe, err = cmd.StdinPipe()
if err != nil {
return fantasy.NewTextErrorResponse("failed to create stdin pipe"), nil
}
}
// Start command execution
if err := cmd.Start(); err != nil {
return fantasy.NewTextErrorResponse(fmt.Sprintf("failed to start command: %v", err)), nil
}
// Write password to stdin if needed, then close stdin
if sudoPassword != "" && stdinPipe != nil {
go func() {
defer func() { _ = stdinPipe.Close() }()
_, _ = io.WriteString(stdinPipe, sudoPassword+"\n")
}()
stdoutPipe, stderrPipe, errResp := setupBashPipes(cmd, sudoPassword)
if errResp != nil {
return *errResp, nil
}
// Stream stdout and stderr concurrently
@@ -391,20 +389,16 @@ func executeBashStreaming(cmdCtx context.Context, call fantasy.ToolCall, cmd *ex
// Wait for the process to exit. cmd.WaitDelay ensures that if pipes
// remain open (held by grandchild processes), they'll be forcibly closed
// after the grace period, which unblocks the scanners above.
err = cmd.Wait()
waitErr := cmd.Wait()
// Wait for the pipe readers to finish draining. This will complete
// quickly since cmd.Wait() (with WaitDelay) has already ensured
// the pipes are closed.
wg.Wait()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
} else if cmdCtx.Err() == context.DeadlineExceeded {
return fantasy.NewTextErrorResponse("command timed out"), nil
}
exitCode, errResp := interpretBashExit(waitErr, cmdCtx)
if errResp != nil {
return *errResp, nil
}
return buildBashResponse(strings.Join(stdoutChunks, "\n"), strings.Join(stderrChunks, "\n"), exitCode)
+3
View File
@@ -83,6 +83,9 @@ func NewEditTool(opts ...ToolOption) fantasy.AgentTool {
}
func executeEdit(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
if err := ctx.Err(); err != nil {
return fantasy.ToolResponse{}, err
}
var args editArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("failed to parse arguments: " + err.Error()), nil
+3
View File
@@ -42,6 +42,9 @@ func NewLsTool(opts ...ToolOption) fantasy.AgentTool {
}
func executeLs(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
if err := ctx.Err(); err != nil {
return fantasy.ToolResponse{}, err
}
var args lsArgs
_ = parseArgs(call.Input, &args) // optional args
+3
View File
@@ -47,6 +47,9 @@ func NewReadTool(opts ...ToolOption) fantasy.AgentTool {
}
func executeRead(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
if err := ctx.Err(); err != nil {
return fantasy.ToolResponse{}, err
}
var args readArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("path parameter is required"), nil
+3
View File
@@ -41,6 +41,9 @@ func NewWriteTool(opts ...ToolOption) fantasy.AgentTool {
}
func executeWrite(ctx context.Context, call fantasy.ToolCall, workDir string) (fantasy.ToolResponse, error) {
if err := ctx.Err(); err != nil {
return fantasy.ToolResponse{}, err
}
var args writeArgs
if err := parseArgs(call.Input, &args); err != nil {
return fantasy.NewTextErrorResponse("path and content parameters are required"), nil
+24 -157
View File
@@ -1,143 +1,32 @@
package extensions
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/mark3labs/kit/internal/watcher"
)
// Watcher monitors extension directories for file changes and triggers
// a reload callback when .go files are created, modified, or removed.
// It uses fsnotify for kernel-level file notifications (inotify on Linux,
// kqueue on macOS) with debouncing to coalesce rapid editor writes.
type Watcher struct {
watcher *fsnotify.Watcher
onReload func()
debounce time.Duration
cancel context.CancelFunc
done chan struct{}
mu sync.Mutex
}
// Watcher monitors extension directories for .go file changes and triggers
// a reload callback when changes are detected. It is implemented in terms
// of the general-purpose internal/watcher.ContentWatcher.
//
// Type-aliasing here lets existing call sites (cmd/root.go and the
// watcher_test.go suite) keep using `extensions.NewWatcher` / `*Watcher`
// without knowing about the underlying implementation.
type Watcher = watcher.ContentWatcher
// NewWatcher creates a file watcher that monitors the given directories
// for .go file changes. When a change is detected (after debouncing),
// onReload is called. The watcher must be started with Start() and
// stopped with Close().
func NewWatcher(dirs []string, onReload func()) (*Watcher, error) {
fsw, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("creating file watcher: %w", err)
}
for _, dir := range dirs {
// Watch the directory itself.
if err := fsw.Add(dir); err != nil {
log.Printf("DEBUG watcher: skipping directory: dir=%s err=%v", dir, err)
continue
}
// Also watch immediate subdirectories (for */main.go pattern).
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() {
subdir := filepath.Join(dir, entry.Name())
if err := fsw.Add(subdir); err != nil {
log.Printf("DEBUG watcher: skipping subdirectory: dir=%s err=%v", subdir, err)
}
}
}
}
return &Watcher{
watcher: fsw,
onReload: onReload,
debounce: 300 * time.Millisecond,
done: make(chan struct{}),
}, nil
}
// Start begins watching for file changes. It blocks until the context
// is cancelled or Close() is called. Typically called in a goroutine.
func (w *Watcher) Start(ctx context.Context) {
w.mu.Lock()
ctx, w.cancel = context.WithCancel(ctx)
w.mu.Unlock()
defer close(w.done)
var timer *time.Timer
var timerC <-chan time.Time
for {
select {
case <-ctx.Done():
if timer != nil {
timer.Stop()
}
return
case event, ok := <-w.watcher.Events:
if !ok {
return
}
// Only care about .go files.
if !strings.HasSuffix(event.Name, ".go") {
continue
}
// React to write, create, remove, rename events.
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove|fsnotify.Rename) == 0 {
continue
}
log.Printf("DEBUG watcher: file changed: file=%s op=%s", event.Name, event.Op)
// Debounce: reset timer on each event.
if timer != nil {
timer.Stop()
}
timer = time.NewTimer(w.debounce)
timerC = timer.C
case <-timerC:
timerC = nil
timer = nil
log.Printf("DEBUG watcher: reloading extensions")
w.onReload()
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
log.Printf("WARN watcher: error: %v", err)
}
}
}
// Close stops the watcher and releases resources.
func (w *Watcher) Close() error {
w.mu.Lock()
cancel := w.cancel
w.mu.Unlock()
if cancel != nil {
cancel()
}
// Wait for the event loop to finish.
<-w.done
return w.watcher.Close()
return watcher.New(watcher.Options{
Dirs: dirs,
Extensions: []string{".go"},
OnReload: onReload,
Label: "extensions",
})
}
// WatchedDirs returns the directories to watch for extension changes.
@@ -146,47 +35,25 @@ func (w *Watcher) Close() error {
// point to directories are also included; explicit file paths cause
// their parent directory to be watched instead.
func WatchedDirs(extraPaths []string) []string {
var dirs []string
seen := make(map[string]bool)
add := func(dir string) {
abs, err := filepath.Abs(dir)
if err != nil {
return
}
if seen[abs] {
return
}
// Verify the directory exists.
info, err := os.Stat(abs)
if err != nil || !info.IsDir() {
return
}
seen[abs] = true
dirs = append(dirs, abs)
standard := []string{
globalExtensionsDir(),
filepath.Join(".kit", "extensions"),
}
// Global extensions dir.
add(globalExtensionsDir())
// Project-local extensions dir.
add(filepath.Join(".kit", "extensions"))
// Explicit paths that are directories.
// Filter explicit paths into directories (passed through) and files
// (parent dir watched) for CollectDirs to dedupe.
var extras []string
for _, p := range extraPaths {
info, err := os.Stat(p)
if err != nil {
continue
}
if info.IsDir() {
add(p)
extras = append(extras, p)
} else {
// For explicit files, watch the parent directory.
add(filepath.Dir(p))
extras = append(extras, filepath.Dir(p))
}
}
return dirs
return watcher.CollectDirs(standard, extras)
}
+282
View File
@@ -0,0 +1,282 @@
package models
import (
"context"
"io"
"net/http"
"reflect"
"strings"
"testing"
)
// TestNpmToWireProtocol documents the wire protocols that the auto-router
// understands. Provider-specific bundles that need bespoke auth or URL
// templating (azure, bedrock, openrouter, google-vertex*, @ai-sdk/gateway)
// are intentionally absent — they have native top-level cases in
// CreateProvider and never reach the auto-router.
func TestNpmToWireProtocol(t *testing.T) {
want := map[string]wireProtocol{
"@ai-sdk/openai": wireOpenAI,
"@ai-sdk/openai-compatible": wireOpenAI,
"@ai-sdk/anthropic": wireAnthropic,
"@ai-sdk/google": wireGoogle,
// Thin OpenAI-compatible wrappers — routed via openaicompat using
// the SDK's hard-coded default base URL (sdkDefaultBaseURL).
"@ai-sdk/groq": wireOpenAI,
"@ai-sdk/cerebras": wireOpenAI,
"@ai-sdk/perplexity": wireOpenAI,
"@ai-sdk/togetherai": wireOpenAI,
"@ai-sdk/xai": wireOpenAI,
"@ai-sdk/deepinfra": wireOpenAI,
"@ai-sdk/mistral": wireOpenAI,
"@ai-sdk/cohere": wireOpenAI,
"@ai-sdk/vercel": wireOpenAI,
"@aihubmix/ai-sdk-provider": wireOpenAI,
"venice-ai-sdk-provider": wireOpenAI,
"merge-gateway-ai-sdk-provider": wireOpenAI,
}
for npm, wire := range want {
if got := npmToWireProtocol[npm]; got != wire {
t.Errorf("npmToWireProtocol[%q] = %d, want %d", npm, got, wire)
}
}
// Bundle packages must NOT be in the table — they need bespoke auth or
// URL templating that the auto-router cannot satisfy.
for _, npm := range []string{
"@ai-sdk/google-vertex",
"@ai-sdk/google-vertex/anthropic",
"@ai-sdk/amazon-bedrock",
"@ai-sdk/azure",
"@openrouter/ai-sdk-provider",
"@ai-sdk/gateway",
} {
if _, ok := npmToWireProtocol[npm]; ok {
t.Errorf("npmToWireProtocol unexpectedly contains bundle package %q", npm)
}
}
}
// newTestRegistry builds a registry containing a single proxy-style provider
// ("testproxy") with the given default npm, plus one model that carries the
// given per-model npm override.
func newTestRegistry(api, defaultNPM, modelID, modelNPMOverride string) *ModelsRegistry {
return &ModelsRegistry{
providers: map[string]ProviderInfo{
"testproxy": {
ID: "testproxy",
Name: "Test Proxy",
Env: []string{"TESTPROXY_API_KEY"},
NPM: defaultNPM,
API: api,
Models: map[string]ModelInfo{
modelID: {
ID: modelID,
Name: modelID,
ProviderNPM: modelNPMOverride,
},
},
},
},
}
}
// TestAutoRouteProvider_WireRouting verifies that autoRouteProvider routes each
// npm package to the correct fantasy provider implementation. This is the core
// regression test for issue #41: previously any npm that resolved to a
// non-openai/anthropic/openaicompat LLM provider (notably @ai-sdk/google) hit a
// dead `default` branch and failed with "has no LLM provider mapping".
func TestAutoRouteProvider_WireRouting(t *testing.T) {
tests := []struct {
name string
modelID string
defaultNPM string
overrideNPM string
// wantType is the concrete fantasy LanguageModel type the model should
// be routed to, identified by reflect type string.
wantType string
}{
{
name: "openai-compatible default",
modelID: "test-model",
defaultNPM: "@ai-sdk/openai-compatible",
wantType: "openai.languageModel",
},
{
name: "anthropic override",
modelID: "test-model",
defaultNPM: "@ai-sdk/openai-compatible",
overrideNPM: "@ai-sdk/anthropic",
wantType: "anthropic.languageModel",
},
{
name: "openai (responses) override",
modelID: "gpt-4o",
defaultNPM: "@ai-sdk/openai-compatible",
overrideNPM: "@ai-sdk/openai",
wantType: "openai.responsesLanguageModel",
},
{
// The bug: opencode's gemini-* models override the default
// openai-compatible npm with @ai-sdk/google.
name: "google override (issue #41)",
modelID: "gemini-3.5-flash",
defaultNPM: "@ai-sdk/openai-compatible",
overrideNPM: "@ai-sdk/google",
wantType: "*google.languageModel",
},
{
// Unknown npm but provider has an API URL → openai-compatible fallback.
name: "unknown npm with API URL falls back to openai-compat",
modelID: "test-model",
defaultNPM: "@ai-sdk/some-future-thing",
wantType: "openai.languageModel",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := newTestRegistry("https://proxy.example/v1", tt.defaultNPM, tt.modelID, tt.overrideNPM)
config := &ProviderConfig{ProviderAPIKey: "test-key"}
result, err := autoRouteProvider(context.Background(), config, "testproxy", tt.modelID, reg)
if err != nil {
t.Fatalf("autoRouteProvider returned error: %v", err)
}
if result == nil || result.Model == nil {
t.Fatalf("autoRouteProvider returned nil model")
}
gotType := reflect.TypeOf(result.Model).String()
if gotType != tt.wantType {
t.Errorf("routed to %s, want %s", gotType, tt.wantType)
}
})
}
}
// TestAutoRouteProvider_UnknownNpmNoAPI verifies the improved error message for
// a provider whose npm has no known wire protocol and that has no API URL to
// fall back on.
func TestAutoRouteProvider_UnknownNpmNoAPI(t *testing.T) {
reg := newTestRegistry("", "@ai-sdk/unmapped", "test-model", "")
config := &ProviderConfig{ProviderAPIKey: "test-key"}
_, err := autoRouteProvider(context.Background(), config, "testproxy", "test-model", reg)
if err == nil {
t.Fatal("expected error for unknown npm with no API URL, got nil")
}
if !strings.Contains(err.Error(), "cannot auto-route provider testproxy") {
t.Errorf("unexpected error message: %v", err)
}
if !strings.Contains(err.Error(), "--provider-url") {
t.Errorf("error should suggest --provider-url, got: %v", err)
}
}
// TestAutoRouteProvider_UnknownProvider verifies the not-in-database error.
func TestAutoRouteProvider_UnknownProvider(t *testing.T) {
reg := newTestRegistry("https://proxy.example/v1", "@ai-sdk/openai-compatible", "test-model", "")
config := &ProviderConfig{ProviderAPIKey: "test-key"}
_, err := autoRouteProvider(context.Background(), config, "does-not-exist", "test-model", reg)
if err == nil {
t.Fatal("expected error for unknown provider, got nil")
}
if !strings.Contains(err.Error(), "not found in model database") {
t.Errorf("unexpected error message: %v", err)
}
}
// TestIsProviderLLMSupported_Google verifies that a provider whose npm is
// @ai-sdk/google is reported as supported (it now maps to a wire protocol).
func TestIsProviderLLMSupported_Google(t *testing.T) {
info := &ProviderInfo{ID: "testproxy", NPM: "@ai-sdk/google"}
if !isProviderLLMSupported("testproxy", info) {
t.Error("expected @ai-sdk/google provider to be LLM-supported")
}
}
// TestVersionedBasePath verifies detection of proxy base URLs that already
// carry an API version segment (which collides with the genai SDK's injected
// version).
func TestVersionedBasePath(t *testing.T) {
tests := []struct {
rawURL string
want string
}{
{"https://opencode.ai/zen/v1", "/zen/v1"},
{"https://opencode.ai/zen/v1/", "/zen/v1"},
{"https://example.com/api/v1beta", "/api/v1beta"},
{"https://example.com/api/v2alpha", "/api/v2alpha"},
{"https://generativelanguage.googleapis.com", ""},
{"https://proxy.example/openai", ""},
{"", ""},
}
for _, tt := range tests {
if got := versionedBasePath(tt.rawURL); got != tt.want {
t.Errorf("versionedBasePath(%q) = %q, want %q", tt.rawURL, got, tt.want)
}
}
}
// recordingRoundTripper captures the path of the request it receives.
type recordingRoundTripper struct{ gotPath string }
func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
r.gotPath = req.URL.Path
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader("{}")),
Header: make(http.Header),
}, nil
}
// TestGeminiProxyTransport_StripsInjectedVersion verifies that the transport
// collapses the genai-injected "/v1beta" segment that follows a proxy base
// URL which already carries its own version segment. This is the second-order
// fix that makes opencode/gemini-* actually reach the proxy (issue #41).
func TestGeminiProxyTransport_StripsInjectedVersion(t *testing.T) {
tests := []struct {
name string
basePath string
reqPath string
wantPath string
}{
{
name: "strips doubled v1beta after /zen/v1",
basePath: "/zen/v1",
reqPath: "/zen/v1/v1beta/models/gemini-3.5-flash:generateContent",
wantPath: "/zen/v1/models/gemini-3.5-flash:generateContent",
},
{
name: "strips doubled v1beta1 after /zen/v1",
basePath: "/zen/v1",
reqPath: "/zen/v1/v1beta1/models/gemini-3.5-flash:generateContent",
wantPath: "/zen/v1/models/gemini-3.5-flash:generateContent",
},
{
name: "leaves non-matching path untouched",
basePath: "/zen/v1",
reqPath: "/other/v1beta/models/x:generateContent",
wantPath: "/other/v1beta/models/x:generateContent",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := &recordingRoundTripper{}
tr := &geminiProxyTransport{base: rec, basePath: tt.basePath}
req, err := http.NewRequest(http.MethodPost, "https://host"+tt.reqPath, nil)
if err != nil {
t.Fatalf("NewRequest: %v", err)
}
if _, err := tr.RoundTrip(req); err != nil {
t.Fatalf("RoundTrip: %v", err)
}
if rec.gotPath != tt.wantPath {
t.Errorf("forwarded path = %q, want %q", rec.gotPath, tt.wantPath)
}
})
}
}
File diff suppressed because one or more lines are too long
+83 -14
View File
@@ -48,18 +48,87 @@ type modelsDBLimit struct {
Output int `json:"output"`
}
// npmToLLMProvider maps npm package names from models.dev to LLM
// provider identifiers. Providers not in this map but with an api URL
// can be auto-routed through openaicompat.
var npmToLLMProvider = map[string]string{
"@ai-sdk/anthropic": "anthropic",
"@ai-sdk/openai": "openai",
"@ai-sdk/google": "google",
"@ai-sdk/google-vertex": "google-vertex",
"@ai-sdk/google-vertex/anthropic": "google-vertex-anthropic",
"@ai-sdk/amazon-bedrock": "bedrock",
"@ai-sdk/azure": "azure",
"@openrouter/ai-sdk-provider": "openrouter",
"@ai-sdk/vercel": "vercel",
"@ai-sdk/openai-compatible": "openaicompat",
// wireProtocol identifies which LLM API protocol an npm package speaks.
// Fantasy implements three native protocols (openai, anthropic, google);
// everything else in its providers/ tree is a thin wrapper around one of
// them with a pre-baked default URL or auth scheme.
type wireProtocol int
const (
wireUnknown wireProtocol = iota
wireOpenAI
wireAnthropic
wireGoogle
)
// npmToWireProtocol maps npm package names from models.dev to the wire
// protocol they speak. Provider-specific bundles that need bespoke auth or
// URL templating (azure, bedrock, openrouter, google-vertex, google-vertex-
// anthropic, and @ai-sdk/gateway which is the Vercel AI Gateway) are
// intentionally absent — they have native top-level cases in CreateProvider
// and never reach the auto-router. Providers not in this map but with an
// api URL are auto-routed through the OpenAI-compatible wire.
//
// The thin OpenAI-compatible npm wrappers (groq, cerebras, mistral, …) are
// listed explicitly so that auto-routing can recover their hard-coded base
// URL from sdkDefaultBaseURL when the registry entry has no api field.
var npmToWireProtocol = map[string]wireProtocol{
// Native wires.
"@ai-sdk/openai": wireOpenAI,
"@ai-sdk/openai-compatible": wireOpenAI,
"@ai-sdk/anthropic": wireAnthropic,
"@ai-sdk/google": wireGoogle,
// Thin OpenAI-compatible wrappers. Each ships with a hard-coded base URL
// in its JS SDK (see sdkDefaultBaseURL) but speaks the plain OpenAI chat
// completions wire — so we can route them all through fantasy's
// openaicompat provider once we supply the URL.
"@ai-sdk/groq": wireOpenAI,
"@ai-sdk/cerebras": wireOpenAI,
"@ai-sdk/perplexity": wireOpenAI,
"@ai-sdk/togetherai": wireOpenAI,
"@ai-sdk/xai": wireOpenAI,
"@ai-sdk/deepinfra": wireOpenAI,
"@ai-sdk/mistral": wireOpenAI,
"@ai-sdk/cohere": wireOpenAI,
"@ai-sdk/vercel": wireOpenAI, // v0 API (api.v0.dev), distinct from @ai-sdk/gateway
"@aihubmix/ai-sdk-provider": wireOpenAI,
"venice-ai-sdk-provider": wireOpenAI,
"merge-gateway-ai-sdk-provider": wireOpenAI,
}
// sdkDefaultBaseURL maps an npm package name to the base URL its JavaScript
// SDK uses by default. This lets us recover a working endpoint for providers
// whose models.dev entry omits the `api` field because the JS SDK hard-codes
// the URL (e.g. groq, cerebras, mistral, x.ai…).
//
// Only OpenAI-compatible and native-wire SDKs are listed; providers needing
// bespoke auth or URL templating (bedrock SigV4, azure resource URLs,
// google-vertex project/location, cloudflare gateway account IDs, gitlab,
// sap-ai-core) are handled by native CreateProvider cases or surface a
// targeted error that asks the user to supply --provider-url.
var sdkDefaultBaseURL = map[string]string{
// Native wires.
"@ai-sdk/openai": "https://api.openai.com/v1",
"@ai-sdk/anthropic": "https://api.anthropic.com/v1",
"@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta",
// Thin OpenAI-compatible wrappers.
"@ai-sdk/groq": "https://api.groq.com/openai/v1",
"@ai-sdk/cerebras": "https://api.cerebras.ai/v1",
"@ai-sdk/perplexity": "https://api.perplexity.ai",
"@ai-sdk/togetherai": "https://api.together.xyz/v1",
"@ai-sdk/xai": "https://api.x.ai/v1",
"@ai-sdk/deepinfra": "https://api.deepinfra.com/v1/openai",
"@ai-sdk/mistral": "https://api.mistral.ai/v1",
"@ai-sdk/cohere": "https://api.cohere.com/compatibility/v1",
"@ai-sdk/vercel": "https://api.v0.dev/v1",
"@aihubmix/ai-sdk-provider": "https://aihubmix.com/v1",
"venice-ai-sdk-provider": "https://api.venice.ai/api/v1",
"merge-gateway-ai-sdk-provider": "https://api-gateway.merge.dev/v1/ai-sdk",
// Native handlers — included for ResolveProviderBaseURL introspection
// even though CreateProvider routes these via dedicated cases.
"@ai-sdk/gateway": "https://ai-gateway.vercel.sh/v1",
"@openrouter/ai-sdk-provider": "https://openrouter.ai/api/v1",
}
+233 -66
View File
@@ -9,7 +9,9 @@ import (
"io"
"maps"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
@@ -220,8 +222,10 @@ func ParseModelString(modelString string) (provider, model string, err error) {
//
// Native providers: anthropic, openai, google, ollama, azure, google-vertex-anthropic,
// openrouter, bedrock, vercel.
// Any provider in models.dev with an api URL or openai-compatible npm package
// is auto-routed through fantasy's openaicompat provider.
// Any other provider in models.dev is auto-routed by wire protocol: its npm
// package (or per-model override) selects the OpenAI, Anthropic, or Google
// transport, using the provider's api URL as the base. Providers with an api
// URL but an unrecognized npm package fall back to the OpenAI-compatible wire.
func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResult, error) {
provider, modelName, err := ParseModelString(config.ModelString)
if err != nil {
@@ -282,13 +286,15 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
result, createErr = createGoogleProvider(ctx, config, modelName)
case "ollama":
result, createErr = createOllamaProvider(ctx, config, modelName)
case "azure":
case "azure", "azure-cognitive-services":
result, createErr = createAzureProvider(ctx, config, modelName)
case "google-vertex-anthropic":
result, createErr = createVertexAnthropicProvider(ctx, config, modelName)
case "google-vertex":
result, createErr = createGoogleVertexProvider(ctx, config, modelName)
case "openrouter":
result, createErr = createOpenRouterProvider(ctx, config, modelName)
case "bedrock":
case "bedrock", "amazon-bedrock":
result, createErr = createBedrockProvider(ctx, config, modelName)
case "vercel":
result, createErr = createVercelProvider(ctx, config, modelName)
@@ -335,44 +341,100 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul
// autoRouteProvider attempts to create a provider by looking up its npm package
// in the models.dev database and routing through the appropriate fantasy provider.
// For openai-compatible providers, it uses the api URL from models.dev.
// Models may have a provider override that specifies a different npm package than
// the provider's default (e.g., opencode's claude-opus-4-6 uses @ai-sdk/anthropic).
// It routes on wire protocol (openai, anthropic, google) rather than per-npm
// provider name: fantasy implements three native wire protocols, and every other
// entry in its providers/ tree is a thin wrapper around one of them. Using the
// provider's api URL from models.dev as the base URL, any proxy that re-flavors
// one of these protocols (e.g. opencode's Gemini routes) Just Works.
//
// Models may carry a provider override that specifies a different npm package
// than the provider's default (e.g. opencode's claude-* uses @ai-sdk/anthropic
// and its gemini-* uses @ai-sdk/google), which is resolved first.
func autoRouteProvider(ctx context.Context, config *ProviderConfig, provider, modelName string, registry *ModelsRegistry) (*ProviderResult, error) {
providerInfo := registry.GetProviderInfo(provider)
if providerInfo == nil {
return nil, fmt.Errorf("unsupported provider: %s (not found in model database)", provider)
}
// Check for model-specific provider override
// Resolve npm: per-model override > provider default.
npmPackage := providerInfo.NPM
if modelInfo := registry.LookupModel(provider, modelName); modelInfo != nil && modelInfo.ProviderNPM != "" {
npmPackage = modelInfo.ProviderNPM
}
// Determine the LLM provider for this npm package
llmProvider := npmToLLMProvider[npmPackage]
if llmProvider == "" && providerInfo.API != "" {
// Unknown npm but has API URL → route through openaicompat
llmProvider = "openaicompat"
wire, known := npmToWireProtocol[npmPackage]
if !known {
// Unknown npm but the provider has an API URL → assume OpenAI-compatible.
// (Preserves the long-standing "any provider in models.dev with an api URL
// is auto-routed through openaicompat" behaviour.)
if providerInfo.API == "" {
return nil, fmt.Errorf(
"cannot auto-route provider %s: npm package %q has no known wire protocol "+
"and the registry has no API URL (use --provider-url to override)",
provider, npmPackage,
)
}
wire = wireOpenAI
}
switch llmProvider {
case "openaicompat":
return createAutoRoutedOpenAICompatProvider(ctx, config, modelName, providerInfo)
case "anthropic":
if config.ProviderURL == "" && providerInfo.API != "" {
// All three wires use the provider's API URL from models.dev as the base.
// When the registry has none, fall back to the SDK's hard-coded default for
// this npm package (covers groq, cerebras, mistral, x.ai, etc. — providers
// whose JS SDK ships a built-in baseURL that models.dev doesn't restate).
if config.ProviderURL == "" {
if providerInfo.API != "" {
config.ProviderURL = providerInfo.API
} else if defaultURL, ok := sdkDefaultBaseURL[npmPackage]; ok {
config.ProviderURL = defaultURL
providerInfo.API = defaultURL // for downstream helpers that read info.API
}
return createAutoRoutedAnthropicProvider(ctx, config, modelName, providerInfo)
case "openai":
if config.ProviderURL == "" && providerInfo.API != "" {
config.ProviderURL = providerInfo.API
}
return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo)
default:
return nil, fmt.Errorf("unsupported provider: %s (npm: %s has no LLM provider mapping)", provider, npmPackage)
}
// Provider templates a runtime account/region/deployment segment into the
// URL (cloudflare-ai-gateway, databricks, snowflake-cortex, gitlab,
// sap-ai-core). Resolve via environment variables, or surface a targeted
// error pointing the user at the right knobs.
if resolved, err := resolveTemplatedAPIURL(config.ProviderURL, providerInfo); err != nil {
return nil, err
} else if resolved != "" {
config.ProviderURL = resolved
providerInfo.API = resolved
}
switch wire {
case wireOpenAI:
// The native OpenAI SDK package (@ai-sdk/openai) speaks the Responses
// API; openai-compatible proxies (and unknown-npm fallbacks) use the
// chat-completions wire via fantasy's openaicompat provider.
if npmPackage == "@ai-sdk/openai" {
return createAutoRoutedOpenAIProvider(ctx, config, modelName, providerInfo)
}
return createAutoRoutedOpenAICompatProvider(ctx, config, modelName, providerInfo)
case wireAnthropic:
return createAutoRoutedAnthropicProvider(ctx, config, modelName, providerInfo)
case wireGoogle:
return createAutoRoutedGoogleProvider(ctx, config, modelName, providerInfo)
default:
return nil, fmt.Errorf("internal error: unknown wire protocol for provider %s (npm: %s)", provider, npmPackage)
}
}
// resolveAutoRouteAPIKey looks up the API key for an auto-routed provider,
// returning a uniform error message when none can be resolved.
func resolveAutoRouteAPIKey(config *ProviderConfig, info *ProviderInfo) (string, error) {
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
if apiKey == "" {
return "", fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
info.Name, strings.Join(info.Env, " / "))
}
return apiKey, nil
}
// wrapProviderErr produces the uniform "failed to create X provider/model: %w"
// error wrap used by every createXxxProvider path. kind is typically
// "provider" or "model".
func wrapProviderErr(name, kind string, err error) error {
return fmt.Errorf("failed to create %s %s: %w", name, kind, err)
}
// createAutoRoutedOpenAICompatProvider creates an openaicompat provider using
@@ -386,10 +448,9 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
return nil, fmt.Errorf("provider %s requires --provider-url (no API URL in database)", info.ID)
}
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
if apiKey == "" {
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
info.Name, strings.Join(info.Env, " / "))
apiKey, err := resolveAutoRouteAPIKey(config, info)
if err != nil {
return nil, err
}
var opts []openaicompat.Option
@@ -403,12 +464,12 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
p, err := openaicompat.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
return nil, wrapProviderErr(info.Name, "provider", err)
}
model, err := p.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
return nil, wrapProviderErr(info.Name, "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -419,10 +480,9 @@ func createAutoRoutedOpenAICompatProvider(ctx context.Context, config *ProviderC
func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
clearConflictingAnthropicSamplingParams(config)
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
if apiKey == "" {
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
info.Name, strings.Join(info.Env, " / "))
apiKey, err := resolveAutoRouteAPIKey(config, info)
if err != nil {
return nil, err
}
var opts []anthropic.Option
@@ -441,12 +501,12 @@ func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConf
p, err := anthropic.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
return nil, wrapProviderErr(info.Name, "provider", err)
}
model, err := p.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
return nil, wrapProviderErr(info.Name, "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -455,10 +515,9 @@ func createAutoRoutedAnthropicProvider(ctx context.Context, config *ProviderConf
// createAutoRoutedOpenAIProvider creates an openai provider for
// third-party providers with openai-compatible APIs.
func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
apiKey := resolveAPIKey(config.ProviderAPIKey, info.Env)
if apiKey == "" {
return nil, fmt.Errorf("%s API key not provided. Use --provider-api-key or set %s",
info.Name, strings.Join(info.Env, " / "))
apiKey, err := resolveAutoRouteAPIKey(config, info)
if err != nil {
return nil, err
}
var opts []openai.Option
@@ -475,12 +534,12 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
p, err := openai.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create %s provider: %w", info.Name, err)
return nil, wrapProviderErr(info.Name, "provider", err)
}
model, err := p.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create %s model: %w", info.Name, err)
return nil, wrapProviderErr(info.Name, "model", err)
}
providerOpts := buildOpenAIProviderOptions(config, modelName)
@@ -488,6 +547,114 @@ func createAutoRoutedOpenAIProvider(ctx context.Context, config *ProviderConfig,
return &ProviderResult{Model: model, ProviderOptions: providerOpts}, nil
}
// createAutoRoutedGoogleProvider creates a Google (Gemini) provider for
// third-party providers that expose a Gemini-compatible API (e.g. opencode's
// Gemini routes, which carry an @ai-sdk/google per-model override).
//
// The underlying genai SDK always injects its own API version segment
// ("v1beta") between the base URL and the resource path. When the proxy's
// base URL from models.dev already carries a version segment (e.g. opencode's
// https://opencode.ai/zen/v1), that produces a doubled ".../v1/v1beta/..."
// path that the proxy rejects. In that case we install a transport that
// strips the injected segment so the proxy's own version is used.
func createAutoRoutedGoogleProvider(ctx context.Context, config *ProviderConfig, modelName string, info *ProviderInfo) (*ProviderResult, error) {
apiKey, err := resolveAutoRouteAPIKey(config, info)
if err != nil {
return nil, err
}
opts := []google.Option{
google.WithGeminiAPIKey(apiKey),
google.WithName(info.ID),
}
if config.ProviderURL != "" {
opts = append(opts, google.WithBaseURL(config.ProviderURL))
}
// Decide whether the genai-injected version segment needs stripping.
var httpClient *http.Client
if basePath := versionedBasePath(config.ProviderURL); basePath != "" {
httpClient = newGeminiProxyHTTPClient(basePath, config.TLSSkipVerify)
} else if config.TLSSkipVerify {
httpClient = createHTTPClientWithTLSConfig(true)
}
if httpClient != nil {
opts = append(opts, google.WithHTTPClient(httpClient))
}
p, err := google.New(opts...)
if err != nil {
return nil, wrapProviderErr(info.Name, "provider", err)
}
model, err := p.LanguageModel(ctx, modelName)
if err != nil {
return nil, wrapProviderErr(info.Name, "model", err)
}
return &ProviderResult{Model: model}, nil
}
// versionSegmentRe matches a trailing API version segment in a URL path,
// e.g. "/v1", "/v1beta", "/v1beta1", "/v2alpha".
var versionSegmentRe = regexp.MustCompile(`/v\d+(?:beta\d*|alpha\d*)?$`)
// versionedBasePath returns the path component of rawURL when that path ends
// with an API version segment (e.g. opencode's ".../zen/v1" → "/zen/v1").
// It returns "" when rawURL is empty, unparseable, or has no version suffix
// — in which case the genai SDK's default version injection is correct and
// no rewriting is needed.
func versionedBasePath(rawURL string) string {
if rawURL == "" {
return ""
}
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
path := strings.TrimSuffix(u.Path, "/")
if versionSegmentRe.MatchString(path) {
return path
}
return ""
}
// newGeminiProxyHTTPClient builds an HTTP client whose transport strips the
// genai-injected version segment ("v1beta"/"v1beta1") that directly follows
// basePath, collapsing "{basePath}/v1beta/..." back to "{basePath}/...".
func newGeminiProxyHTTPClient(basePath string, skipVerify bool) *http.Client {
var base http.RoundTripper
if skipVerify {
base = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
} else {
base = http.DefaultTransport
}
return &http.Client{
Transport: &geminiProxyTransport{base: base, basePath: basePath},
}
}
// geminiProxyTransport removes the redundant API version segment that the
// genai SDK injects after a proxy base URL that already carries its own
// version segment.
type geminiProxyTransport struct {
base http.RoundTripper
basePath string
}
func (t *geminiProxyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
for _, injected := range []string{"/v1beta1", "/v1beta"} {
prefix := t.basePath + injected + "/"
if strings.HasPrefix(req.URL.Path, prefix) {
newReq := req.Clone(req.Context())
newReq.URL.Path = t.basePath + strings.TrimPrefix(req.URL.Path, t.basePath+injected)
return t.base.RoundTrip(newReq)
}
}
return t.base.RoundTrip(req)
}
// resolveAPIKey returns the first non-empty API key from the explicit key
// or the environment variables.
func resolveAPIKey(explicitKey string, envVars []string) string {
@@ -727,12 +894,12 @@ func createAnthropicProvider(ctx context.Context, config *ProviderConfig, modelN
provider, err := anthropic.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Anthropic provider: %w", err)
return nil, wrapProviderErr("Anthropic", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Anthropic model: %w", err)
return nil, wrapProviderErr("Anthropic", "model", err)
}
// Build provider options for extended thinking (reasoning budget).
@@ -769,12 +936,12 @@ func createVertexAnthropicProvider(ctx context.Context, config *ProviderConfig,
provider, err := anthropic.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Vertex Anthropic provider: %w", err)
return nil, wrapProviderErr("Vertex Anthropic", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Vertex Anthropic model: %w", err)
return nil, wrapProviderErr("Vertex Anthropic", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -842,12 +1009,12 @@ func createOpenAIProvider(ctx context.Context, config *ProviderConfig, modelName
provider, err := openai.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create OpenAI provider: %w", err)
return nil, wrapProviderErr("OpenAI", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create OpenAI model: %w", err)
return nil, wrapProviderErr("OpenAI", "model", err)
}
// Build provider options for OpenAI Responses API reasoning models.
@@ -883,12 +1050,12 @@ func createOpenAICodexProvider(ctx context.Context, config *ProviderConfig, mode
provider, err := openai.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create OpenAI Codex provider: %w", err)
return nil, wrapProviderErr("OpenAI Codex", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create OpenAI Codex model: %w", err)
return nil, wrapProviderErr("OpenAI Codex", "model", err)
}
providerOpts := buildCodexProviderOptions(config, modelName)
@@ -1001,12 +1168,12 @@ func createGoogleProvider(ctx context.Context, config *ProviderConfig, modelName
provider, err := google.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Google provider: %w", err)
return nil, wrapProviderErr("Google", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Google model: %w", err)
return nil, wrapProviderErr("Google", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -1039,12 +1206,12 @@ func createAzureProvider(ctx context.Context, config *ProviderConfig, modelName
provider, err := azure.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Azure OpenAI provider: %w", err)
return nil, wrapProviderErr("Azure OpenAI", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Azure OpenAI model: %w", err)
return nil, wrapProviderErr("Azure OpenAI", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -1064,12 +1231,12 @@ func createOpenRouterProvider(ctx context.Context, config *ProviderConfig, model
provider, err := openrouter.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create OpenRouter provider: %w", err)
return nil, wrapProviderErr("OpenRouter", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create OpenRouter model: %w", err)
return nil, wrapProviderErr("OpenRouter", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -1081,12 +1248,12 @@ func createBedrockProvider(ctx context.Context, config *ProviderConfig, modelNam
// Bedrock uses AWS SDK default credential chain (env vars, shared config, etc.)
provider, err := bedrock.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Bedrock provider: %w", err)
return nil, wrapProviderErr("Bedrock", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Bedrock model: %w", err)
return nil, wrapProviderErr("Bedrock", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -1110,12 +1277,12 @@ func createVercelProvider(ctx context.Context, config *ProviderConfig, modelName
provider, err := vercel.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Vercel provider: %w", err)
return nil, wrapProviderErr("Vercel", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Vercel model: %w", err)
return nil, wrapProviderErr("Vercel", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -1168,12 +1335,12 @@ func createCustomProvider(ctx context.Context, config *ProviderConfig, modelName
p, err := openai.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create custom provider: %w", err)
return nil, wrapProviderErr("custom", "provider", err)
}
model, err := p.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create custom model: %w", err)
return nil, wrapProviderErr("custom", "model", err)
}
return &ProviderResult{Model: model}, nil
@@ -1217,12 +1384,12 @@ func createOllamaProvider(ctx context.Context, config *ProviderConfig, modelName
provider, err := openaicompat.New(opts...)
if err != nil {
return nil, fmt.Errorf("failed to create Ollama provider: %w", err)
return nil, wrapProviderErr("Ollama", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, fmt.Errorf("failed to create Ollama model: %w", err)
return nil, wrapProviderErr("Ollama", "model", err)
}
return &ProviderResult{
+2 -2
View File
@@ -404,8 +404,8 @@ func isProviderLLMSupported(providerID string, info *ProviderInfo) bool {
return true
}
// Check if npm maps to an LLM provider
if _, ok := npmToLLMProvider[info.NPM]; ok {
// Check if npm maps to a known wire protocol
if _, ok := npmToWireProtocol[info.NPM]; ok {
return true
}
+170
View File
@@ -0,0 +1,170 @@
package models
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"charm.land/fantasy/providers/google"
)
// templatePlaceholderRe matches "${NAME}" placeholders in URL templates from
// models.dev (e.g. "https://${DATABRICKS_HOST}/ai-gateway/mlflow/v1").
var templatePlaceholderRe = regexp.MustCompile(`\$\{([A-Z0-9_]+)\}`)
// templateEnvVarOverrides supplies fallback environment variable names for
// placeholders that providers commonly use under non-obvious env names.
// The placeholder name itself is always tried first; this map adds extra
// names to try when the placeholder doesn't match the canonical env var.
var templateEnvVarOverrides = map[string][]string{
"CLOUDFLARE_ACCOUNT_ID": {"CF_ACCOUNT_ID"},
"CLOUDFLARE_GATEWAY_NAME": {"CF_GATEWAY", "CLOUDFLARE_GATEWAY"},
"DATABRICKS_HOST": {"DATABRICKS_WORKSPACE_URL"},
"SNOWFLAKE_ACCOUNT": {"SNOWFLAKE_ACCOUNT_ID"},
}
// resolveTemplatedAPIURL substitutes "${VAR}" placeholders in apiURL with the
// values of the named environment variables. Returns:
// - ("", nil) when apiURL contains no placeholders (caller keeps current URL),
// - (resolved, nil) when every placeholder was resolved,
// - ("", error) when one or more placeholders are unset, with a message that
// names the missing env vars and points at the relevant provider.
//
// The info parameter is used purely for error messaging (provider name).
func resolveTemplatedAPIURL(apiURL string, info *ProviderInfo) (string, error) {
if apiURL == "" || !strings.Contains(apiURL, "${") {
return "", nil
}
var missing []string
resolved := templatePlaceholderRe.ReplaceAllStringFunc(apiURL, func(match string) string {
// match is "${NAME}". Extract NAME.
name := match[2 : len(match)-1]
if v := os.Getenv(name); v != "" {
return v
}
for _, alt := range templateEnvVarOverrides[name] {
if v := os.Getenv(alt); v != "" {
return v
}
}
missing = append(missing, name)
return match
})
if len(missing) > 0 {
providerName := info.ID
if info.Name != "" {
providerName = info.Name
}
return "", fmt.Errorf(
"provider %s requires environment variable(s) %s to construct its API URL (%s); "+
"set them or pass --provider-url to override",
providerName, strings.Join(missing, ", "), apiURL,
)
}
return resolved, nil
}
// ResolveProviderBaseURL returns the base API URL kit will use when talking to
// the given provider, applying the same resolution order as CreateProvider:
//
// 1. The provider's `api` field from the models.dev registry.
// 2. The hard-coded default base URL of its npm SDK package (e.g.
// @ai-sdk/groq → https://api.groq.com/openai/v1).
// 3. Template substitution against the current process environment when the
// URL contains "${VAR}" placeholders (e.g. cloudflare-workers-ai needs
// CLOUDFLARE_ACCOUNT_ID).
//
// It returns an error when the provider is unknown, when no URL can be derived,
// or when a templated URL has unset placeholders. The error message is suitable
// for direct display to end users.
//
// Note: providers handled by bespoke auth schemes (amazon-bedrock SigV4,
// azure resource URLs, google-vertex project/location, sap-ai-core customer
// deployments) may return either an empty URL or a regional/templated URL —
// the actual endpoint is finalised inside their native handlers and depends on
// runtime credentials.
func ResolveProviderBaseURL(providerID string) (string, error) {
registry := GetGlobalRegistry()
info := registry.GetProviderInfo(providerID)
if info == nil {
return "", fmt.Errorf("unknown provider: %s", providerID)
}
apiURL := info.API
if apiURL == "" {
if defaultURL, ok := sdkDefaultBaseURL[info.NPM]; ok {
apiURL = defaultURL
}
}
if apiURL == "" {
return "", fmt.Errorf(
"provider %s has no default API URL: its npm package %q does not "+
"ship a built-in baseURL (likely Bedrock SigV4, Azure deployment, "+
"Vertex project/location, or a customer-hosted endpoint). "+
"Pass --provider-url or set the provider's URL env var",
providerID, info.NPM,
)
}
if strings.Contains(apiURL, "${") {
resolved, err := resolveTemplatedAPIURL(apiURL, info)
if err != nil {
return apiURL, err
}
return resolved, nil
}
return apiURL, nil
}
// createGoogleVertexProvider creates a Google Gemini provider that targets the
// Vertex AI backend (rather than the public generativelanguage.googleapis.com
// endpoint). It requires the same project/region environment variables as
// google-vertex-anthropic.
func createGoogleVertexProvider(ctx context.Context, config *ProviderConfig, modelName string) (*ProviderResult, error) {
projectID := firstNonEmpty(
os.Getenv("GOOGLE_VERTEX_PROJECT"),
os.Getenv("GOOGLE_CLOUD_PROJECT"),
os.Getenv("GCLOUD_PROJECT"),
os.Getenv("CLOUDSDK_CORE_PROJECT"),
)
if projectID == "" {
return nil, fmt.Errorf(
"google Vertex project ID not provided, set GOOGLE_VERTEX_PROJECT, " +
"GOOGLE_CLOUD_PROJECT, or GCLOUD_PROJECT environment variable",
)
}
region := firstNonEmpty(
os.Getenv("GOOGLE_VERTEX_LOCATION"),
os.Getenv("CLOUD_ML_REGION"),
)
if region == "" {
region = "global"
}
opts := []google.Option{
google.WithVertex(projectID, region),
google.WithName("google-vertex"),
}
if config.TLSSkipVerify {
opts = append(opts, google.WithHTTPClient(createHTTPClientWithTLSConfig(true)))
}
provider, err := google.New(opts...)
if err != nil {
return nil, wrapProviderErr("Google Vertex", "provider", err)
}
model, err := provider.LanguageModel(ctx, modelName)
if err != nil {
return nil, wrapProviderErr("Google Vertex", "model", err)
}
return &ProviderResult{Model: model}, nil
}
+214
View File
@@ -0,0 +1,214 @@
package models
import (
"context"
"reflect"
"strings"
"testing"
)
// TestSDKDefaultBaseURL_CoversAllWireMappedPackages enforces the invariant
// that every npm package recognised by the auto-router has a corresponding
// default base URL — otherwise a provider that omits its `api` field in the
// registry would silently fail to route at runtime.
func TestSDKDefaultBaseURL_CoversAllWireMappedPackages(t *testing.T) {
for npm := range npmToWireProtocol {
// @ai-sdk/openai-compatible is a wire family, not a single SDK with
// a default URL — providers using it always supply their own `api`.
if npm == "@ai-sdk/openai-compatible" {
continue
}
if _, ok := sdkDefaultBaseURL[npm]; !ok {
t.Errorf("npm %q is in npmToWireProtocol but has no sdkDefaultBaseURL entry — "+
"providers using this npm with no `api` field cannot be routed", npm)
}
}
}
// TestSDKDefaultBaseURL_AllURLsAreAbsolute sanity-checks that every default
// URL is a well-formed absolute https endpoint (catches typos in the table).
func TestSDKDefaultBaseURL_AllURLsAreAbsolute(t *testing.T) {
for npm, url := range sdkDefaultBaseURL {
if !strings.HasPrefix(url, "https://") {
t.Errorf("sdkDefaultBaseURL[%q] = %q is not an absolute https URL", npm, url)
}
}
}
// TestResolveProviderBaseURL_RegistryFirst verifies that the registry's `api`
// field wins over any SDK default.
func TestResolveProviderBaseURL_RegistryFirst(t *testing.T) {
// xai is in the registry with no `api` field — its URL comes from the
// SDK default. Use a synthetic registry-backed provider to test the
// priority via the public registry instead.
url, err := ResolveProviderBaseURL("openai")
if err != nil {
t.Fatalf("ResolveProviderBaseURL(openai): %v", err)
}
if url != "https://api.openai.com/v1" {
t.Errorf("openai URL = %q, want https://api.openai.com/v1", url)
}
}
// TestResolveProviderBaseURL_SDKDefaultFallback verifies that providers
// without an `api` field (groq, cerebras, xai, …) resolve to their SDK
// hard-coded default URL.
func TestResolveProviderBaseURL_SDKDefaultFallback(t *testing.T) {
tests := map[string]string{
"groq": "https://api.groq.com/openai/v1",
"cerebras": "https://api.cerebras.ai/v1",
"xai": "https://api.x.ai/v1",
"mistral": "https://api.mistral.ai/v1",
"perplexity": "https://api.perplexity.ai",
"togetherai": "https://api.together.xyz/v1",
"deepinfra": "https://api.deepinfra.com/v1/openai",
"cohere": "https://api.cohere.com/compatibility/v1",
"v0": "https://api.v0.dev/v1",
"aihubmix": "https://aihubmix.com/v1",
"venice": "https://api.venice.ai/api/v1",
"openrouter": "https://openrouter.ai/api/v1",
}
for providerID, wantURL := range tests {
t.Run(providerID, func(t *testing.T) {
got, err := ResolveProviderBaseURL(providerID)
if err != nil {
t.Fatalf("ResolveProviderBaseURL(%s): %v", providerID, err)
}
if got != wantURL {
t.Errorf("%s URL = %q, want %q", providerID, got, wantURL)
}
})
}
}
// TestResolveProviderBaseURL_TemplatedURL_MissingEnv verifies that providers
// whose URL contains "${VAR}" placeholders surface a targeted error when the
// environment variables are unset.
func TestResolveProviderBaseURL_TemplatedURL_MissingEnv(t *testing.T) {
// cloudflare-workers-ai's api URL contains ${CLOUDFLARE_ACCOUNT_ID}.
// Ensure the variable is unset for this test.
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "")
t.Setenv("CF_ACCOUNT_ID", "")
_, err := ResolveProviderBaseURL("cloudflare-workers-ai")
if err == nil {
t.Fatal("expected error for unset CLOUDFLARE_ACCOUNT_ID, got nil")
}
if !strings.Contains(err.Error(), "CLOUDFLARE_ACCOUNT_ID") {
t.Errorf("error should name the missing env var, got: %v", err)
}
if !strings.Contains(err.Error(), "--provider-url") {
t.Errorf("error should suggest --provider-url override, got: %v", err)
}
}
// TestResolveProviderBaseURL_TemplatedURL_Resolved verifies env-var
// substitution succeeds when the placeholder is set.
func TestResolveProviderBaseURL_TemplatedURL_Resolved(t *testing.T) {
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "test-acct-123")
got, err := ResolveProviderBaseURL("cloudflare-workers-ai")
if err != nil {
t.Fatalf("ResolveProviderBaseURL: %v", err)
}
if !strings.Contains(got, "test-acct-123") {
t.Errorf("resolved URL %q should contain test-acct-123", got)
}
if strings.Contains(got, "${") {
t.Errorf("resolved URL %q still contains template placeholder", got)
}
}
// TestResolveProviderBaseURL_UnknownProvider verifies the not-in-registry error.
func TestResolveProviderBaseURL_UnknownProvider(t *testing.T) {
_, err := ResolveProviderBaseURL("does-not-exist")
if err == nil {
t.Fatal("expected error for unknown provider, got nil")
}
if !strings.Contains(err.Error(), "unknown provider") {
t.Errorf("error should say 'unknown provider', got: %v", err)
}
}
// TestAutoRouteProvider_SDKDefaultURLFallback verifies that providers whose
// registry entry omits the `api` field (groq, mistral, xai, etc.) are still
// auto-routed by falling back to the SDK's hard-coded default URL.
func TestAutoRouteProvider_SDKDefaultURLFallback(t *testing.T) {
tests := []struct {
name string
npmPackage string
wantInURL string
}{
{"groq", "@ai-sdk/groq", "groq.com"},
{"cerebras", "@ai-sdk/cerebras", "cerebras.ai"},
{"xai", "@ai-sdk/xai", "x.ai"},
{"mistral", "@ai-sdk/mistral", "mistral.ai"},
{"v0", "@ai-sdk/vercel", "v0.dev"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &ModelsRegistry{
providers: map[string]ProviderInfo{
"testfallback": {
ID: "testfallback",
Name: "Test Fallback",
Env: []string{"TESTFALLBACK_API_KEY"},
NPM: tt.npmPackage,
// API intentionally omitted — must fall back to SDK default.
Models: map[string]ModelInfo{
"any-model": {ID: "any-model", Name: "any-model"},
},
},
},
}
config := &ProviderConfig{ProviderAPIKey: "test-key"}
result, err := autoRouteProvider(context.Background(), config, "testfallback", "any-model", reg)
if err != nil {
t.Fatalf("autoRouteProvider returned error: %v", err)
}
if result == nil || result.Model == nil {
t.Fatal("autoRouteProvider returned nil model")
}
// Verify the SDK default URL was picked up.
if !strings.Contains(config.ProviderURL, tt.wantInURL) {
t.Errorf("config.ProviderURL = %q, want substring %q (SDK default)",
config.ProviderURL, tt.wantInURL)
}
// All these wrappers route through the openai-compat wire.
gotType := reflect.TypeOf(result.Model).String()
if gotType != "openai.languageModel" {
t.Errorf("model type = %q, want openai.languageModel", gotType)
}
})
}
}
// TestResolveTemplatedAPIURL_NoPlaceholders verifies that URLs without
// placeholders are returned as-is (the caller keeps using the original).
func TestResolveTemplatedAPIURL_NoPlaceholders(t *testing.T) {
got, err := resolveTemplatedAPIURL("https://api.example.com/v1", &ProviderInfo{ID: "x"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Errorf("got %q, want empty string for URL with no placeholders", got)
}
}
// TestResolveTemplatedAPIURL_AltEnvVar verifies that the alternative env-var
// names (e.g. CF_ACCOUNT_ID for CLOUDFLARE_ACCOUNT_ID) are honoured.
func TestResolveTemplatedAPIURL_AltEnvVar(t *testing.T) {
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "")
t.Setenv("CF_ACCOUNT_ID", "alt-name-123")
got, err := resolveTemplatedAPIURL(
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
&ProviderInfo{ID: "cloudflare-workers-ai"},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(got, "alt-name-123") {
t.Errorf("resolved URL %q should have picked up CF_ACCOUNT_ID alternative", got)
}
}
-15
View File
@@ -458,11 +458,6 @@ func (tm *TreeManager) AppendLLMMessage(msg fantasy.Message) (string, error) {
return tm.AppendMessage(message.FromLLMMessage(msg))
}
// Deprecated: Use AppendLLMMessage instead.
func (tm *TreeManager) AppendFantasyMessage(msg fantasy.Message) (string, error) {
return tm.AppendLLMMessage(msg)
}
// AppendModelChange records a model/provider change.
func (tm *TreeManager) AppendModelChange(provider, modelID string) (string, error) {
tm.mu.Lock()
@@ -1170,11 +1165,6 @@ func (tm *TreeManager) AddLLMMessages(msgs []fantasy.Message) error {
return tm.flushLocked()
}
// Deprecated: Use AddLLMMessages instead.
func (tm *TreeManager) AddFantasyMessages(msgs []fantasy.Message) error {
return tm.AddLLMMessages(msgs)
}
// GetLLMMessages builds the context and returns just the messages.
// This satisfies the same conceptual role as the old Manager.GetMessages().
func (tm *TreeManager) GetLLMMessages() []fantasy.Message {
@@ -1182,11 +1172,6 @@ func (tm *TreeManager) GetLLMMessages() []fantasy.Message {
return msgs
}
// Deprecated: Use GetLLMMessages instead.
func (tm *TreeManager) GetFantasyMessages() []fantasy.Message {
return tm.GetLLMMessages()
}
// --- Internal helpers ---
// addEntryToIndex adds an entry to the in-memory indices.
+233
View File
@@ -0,0 +1,233 @@
// Package imagepreview renders low-resolution, in-terminal thumbnails of
// images using Unicode upper half-block characters (U+2580, "▀") combined
// with SGR foreground/background color codes.
//
// The technique stacks two vertical pixels into a single character cell: the
// foreground color paints the top pixel and the background color paints the
// bottom pixel. This produces pure styled text — no graphics escape sequences
// — so the output survives terminal multiplexers (tmux, zellij) untouched.
//
// The Kitty graphics protocol, Sixel, and iTerm2 inline images are
// deliberately NOT used: those are graphics escape-sequence protocols that
// tmux and zellij strip or mangle by default.
package imagepreview
import (
"bytes"
"fmt"
"image"
"image/color"
"os"
"strings"
// Register the standard image decoders so image.Decode can handle the
// common clipboard / attachment formats.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
xdraw "golang.org/x/image/draw"
)
// upperHalfBlock is U+2580 ("▀"). The glyph fills the top half of a cell,
// letting the foreground color render the top pixel and the cell's background
// color render the bottom pixel.
const upperHalfBlock = "▀"
// reset is the SGR reset sequence appended after each rendered row.
const reset = "\x1b[0m"
// maxImageDimension is the largest width or height, in pixels, that Render will
// fully decode. Images larger than this in either axis are rejected before the
// expensive image.Decode call to guard against decompression bombs (small
// encoded payloads that expand to enormous pixel buffers).
const maxImageDimension = 20000
// Render returns a half-block ANSI thumbnail of the image, scaled to fit
// within maxCols x maxRows terminal cells while preserving aspect ratio.
//
// Each terminal cell encodes two vertically-stacked pixels, so the effective
// pixel resolution of the thumbnail is up to maxCols x (maxRows*2).
//
// Colors are emitted at the fidelity of the detected terminal color profile:
// truecolor (24-bit) when available, degrading to 256-color. When the
// terminal supports neither (no truecolor and no 256-color), Render returns
// an empty string and a nil error so the caller can fall back to a text
// indicator. A non-nil error is only returned when the image data cannot be
// decoded.
//
// bg is the color used to composite transparent pixels (typically the
// terminal background). A nil bg defaults to black.
func Render(data []byte, mediaType string, maxCols, maxRows int, bg color.Color) (string, error) {
profile := colorprofile.Env(os.Environ())
return renderWithProfile(data, maxCols, maxRows, bg, profile)
}
// renderWithProfile is the testable core of Render. It accepts an explicit
// color profile instead of detecting one from the environment.
func renderWithProfile(data []byte, maxCols, maxRows int, bg color.Color, profile colorprofile.Profile) (string, error) {
// Half-block fidelity needs at least 256-color support. Anything less
// degrades to the caller's text fallback.
if profile < colorprofile.ANSI256 {
return "", nil
}
if maxCols < 1 || maxRows < 1 {
return "", nil
}
if bg == nil {
bg = color.Black
}
// Guard against decompression bombs: inspect the header dimensions before
// fully decoding, so a small malicious payload cannot expand into an
// enormous pixel buffer.
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("decode image config: %w", err)
}
if cfg.Width > maxImageDimension || cfg.Height > maxImageDimension {
return "", fmt.Errorf("decode image: dimensions %dx%d exceed limit %d", cfg.Width, cfg.Height, maxImageDimension)
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("decode image: %w", err)
}
// Target pixel dimensions: one pixel per column horizontally and two
// pixels per row vertically (the half-block trick).
cols, rows := fitDimensions(img.Bounds().Dx(), img.Bounds().Dy(), maxCols, maxRows)
if cols < 1 || rows < 1 {
return "", nil
}
pxW, pxH := cols, rows*2
scaled := image.NewRGBA(image.Rect(0, 0, pxW, pxH))
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), xdraw.Over, nil)
var b strings.Builder
for y := 0; y < pxH; y += 2 {
for x := range pxW {
top := composite(scaled.At(x, y), bg)
bottom := composite(scaled.At(x, y+1), bg)
b.WriteString(sgr(top, bottom, profile))
b.WriteString(upperHalfBlock)
}
b.WriteString(reset)
if y+2 < pxH {
b.WriteByte('\n')
}
}
return b.String(), nil
}
// fitDimensions returns the largest cell dimensions (cols, rows) that fit a
// srcW x srcH image inside a maxCols x maxRows box while preserving aspect
// ratio. Because each cell stacks two vertical pixels, a terminal cell is
// treated as roughly twice as tall as it is wide, which keeps the thumbnail's
// aspect ratio visually correct.
func fitDimensions(srcW, srcH, maxCols, maxRows int) (cols, rows int) {
if srcW <= 0 || srcH <= 0 {
return 0, 0
}
// Work in pixel space: the box is maxCols wide and maxRows*2 tall.
maxPxW := float64(maxCols)
maxPxH := float64(maxRows * 2)
scale := maxPxW / float64(srcW)
if h := maxPxH / float64(srcH); h < scale {
scale = h
}
if scale > 1 {
scale = 1 // never upscale; keep the low-res look
}
pxW := int(float64(srcW) * scale)
pxH := int(float64(srcH) * scale)
if pxW < 1 {
pxW = 1
}
if pxH < 2 {
pxH = 2
}
// Convert back to cells; round the row count up to an even pixel height.
cols = pxW
rows = (pxH + 1) / 2
if cols > maxCols {
cols = maxCols
}
if rows > maxRows {
rows = maxRows
}
return cols, rows
}
// composite blends a (possibly translucent) pixel over the background color,
// returning an opaque color. Fully opaque pixels are returned unchanged.
func composite(c, bg color.Color) color.Color {
r, g, b, a := c.RGBA()
if a == 0xffff {
return c
}
br, bgc, bb, _ := bg.RGBA()
// Standard "over" alpha compositing in 16-bit space.
inv := 0xffff - a
out := color.RGBA64{
R: uint16(r + br*inv/0xffff),
G: uint16(g + bgc*inv/0xffff),
B: uint16(b + bb*inv/0xffff),
A: 0xffff,
}
return out
}
// sgr builds the SGR escape sequence that sets the foreground (top pixel) and
// background (bottom pixel) colors at the fidelity of the given profile.
func sgr(fg, bg color.Color, profile colorprofile.Profile) string {
if profile >= colorprofile.TrueColor {
fr, fgc, fb := rgb8(fg)
br, bgc, bb := rgb8(bg)
return fmt.Sprintf("\x1b[38;2;%d;%d;%d;48;2;%d;%d;%dm", fr, fgc, fb, br, bgc, bb)
}
return fmt.Sprintf("\x1b[38;5;%d;48;5;%dm", index256(fg, profile), index256(bg, profile))
}
// rgb8 reduces a color to 8-bit RGB components.
func rgb8(c color.Color) (r, g, b uint8) {
cr, cg, cb, _ := c.RGBA()
return uint8(cr >> 8), uint8(cg >> 8), uint8(cb >> 8)
}
// index256 converts a color to its nearest 256-color palette index using the
// supplied profile.
func index256(c color.Color, profile colorprofile.Profile) uint8 {
cc := profile.Convert(c)
if idx, ok := cc.(ansi.IndexedColor); ok {
return uint8(idx)
}
if idx, ok := cc.(ansi.BasicColor); ok {
return uint8(idx)
}
// Fallback: derive an index directly if conversion produced an
// unexpected type.
r, g, b := rgb8(c)
return ansi256FromRGB(r, g, b)
}
// ansi256FromRGB maps an 8-bit RGB color to the xterm 256-color cube. It is a
// best-effort fallback used only when profile.Convert does not yield a known
// indexed color type.
func ansi256FromRGB(r, g, b uint8) uint8 {
q := func(v uint8) int {
switch {
case v < 48:
return 0
case v < 115:
return 1
default:
return int((v - 35) / 40)
}
}
ri, gi, bi := q(r), q(g), q(b)
return uint8(16 + 36*ri + 6*gi + bi)
}
@@ -0,0 +1,193 @@
package imagepreview
import (
"bytes"
"image"
"image/color"
"image/png"
"strings"
"testing"
"github.com/charmbracelet/colorprofile"
)
// makePNG builds a simple w x h PNG filled with the given color and returns
// its encoded bytes.
func makePNG(t *testing.T, w, h int, c color.Color) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
func TestRenderTrueColor(t *testing.T) {
data := makePNG(t, 20, 20, color.RGBA{R: 255, A: 255})
out, err := renderWithProfile(data, 10, 5, color.Black, colorprofile.TrueColor)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == "" {
t.Fatal("expected non-empty thumbnail for truecolor profile")
}
if !strings.Contains(out, upperHalfBlock) {
t.Error("output should contain upper half block glyphs")
}
if !strings.Contains(out, "\x1b[38;2;") || !strings.Contains(out, "48;2;") {
t.Errorf("expected truecolor SGR sequences, got %q", out)
}
// Red fill should appear as 255;0;0 somewhere.
if !strings.Contains(out, "255;0;0") {
t.Errorf("expected red color in output, got %q", out)
}
}
func TestRenderANSI256(t *testing.T) {
data := makePNG(t, 20, 20, color.RGBA{G: 255, A: 255})
out, err := renderWithProfile(data, 8, 4, color.Black, colorprofile.ANSI256)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == "" {
t.Fatal("expected non-empty thumbnail for ANSI256 profile")
}
if !strings.Contains(out, "\x1b[38;5;") || !strings.Contains(out, "48;5;") {
t.Errorf("expected 256-color SGR sequences, got %q", out)
}
if strings.Contains(out, "38;2;") {
t.Errorf("ANSI256 output should not contain truecolor sequences, got %q", out)
}
}
func TestRenderDegradesBelowANSI256(t *testing.T) {
data := makePNG(t, 20, 20, color.RGBA{B: 255, A: 255})
for _, p := range []colorprofile.Profile{colorprofile.ANSI, colorprofile.ASCII, colorprofile.NoTTY} {
out, err := renderWithProfile(data, 10, 5, color.Black, p)
if err != nil {
t.Fatalf("profile %v: unexpected error: %v", p, err)
}
if out != "" {
t.Errorf("profile %v: expected empty fallback, got %q", p, out)
}
}
}
func TestRenderInvalidImage(t *testing.T) {
out, err := renderWithProfile([]byte("not an image"), 10, 5, color.Black, colorprofile.TrueColor)
if err == nil {
t.Fatal("expected error for invalid image data")
}
if out != "" {
t.Errorf("expected empty output on decode error, got %q", out)
}
}
func TestRenderRejectsOversizedImage(t *testing.T) {
// A header advertising dimensions beyond maxImageDimension must be
// rejected before full decode (decompression-bomb guard). image.RGBA
// allocation is avoided by only checking the config path here.
w := maxImageDimension + 1
data := makePNG(t, w, 1, color.White)
out, err := renderWithProfile(data, 10, 5, color.Black, colorprofile.TrueColor)
if err == nil {
t.Fatal("expected error for oversized image dimensions")
}
if out != "" {
t.Errorf("expected empty output for oversized image, got %q", out)
}
}
func TestRenderZeroBox(t *testing.T) {
data := makePNG(t, 20, 20, color.White)
out, err := renderWithProfile(data, 0, 0, color.Black, colorprofile.TrueColor)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out != "" {
t.Errorf("expected empty output for zero-sized box, got %q", out)
}
}
func TestRenderNilBackgroundDefaults(t *testing.T) {
data := makePNG(t, 10, 10, color.RGBA{R: 10, G: 20, B: 30, A: 255})
out, err := renderWithProfile(data, 6, 3, nil, colorprofile.TrueColor)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out == "" {
t.Fatal("expected output with nil background (defaults to black)")
}
}
func TestRowCountWithinBounds(t *testing.T) {
// A tall image should be capped at maxRows cells.
data := makePNG(t, 10, 100, color.White)
out, err := renderWithProfile(data, 20, 6, color.Black, colorprofile.TrueColor)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
rows := strings.Count(out, "\n") + 1
if rows > 6 {
t.Errorf("expected at most 6 rows, got %d", rows)
}
}
func TestColumnCountWithinBounds(t *testing.T) {
// A wide image should be capped at maxCols cells per row.
data := makePNG(t, 100, 10, color.White)
out, err := renderWithProfile(data, 8, 20, color.Black, colorprofile.TrueColor)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
firstRow := strings.SplitN(out, "\n", 2)[0]
cols := strings.Count(firstRow, upperHalfBlock)
if cols > 8 {
t.Errorf("expected at most 8 columns, got %d", cols)
}
if cols == 0 {
t.Error("expected at least one column")
}
}
func TestFitDimensionsPreservesAspect(t *testing.T) {
// 2:1 (wide) image into a 40x20 box. Pixel box is 40x40; width-bound.
cols, rows := fitDimensions(200, 100, 40, 20)
if cols != 40 {
t.Errorf("expected 40 cols, got %d", cols)
}
// pxH = 100 * (40/200) = 20 → 10 rows.
if rows != 10 {
t.Errorf("expected 10 rows, got %d", rows)
}
}
func TestFitDimensionsNeverUpscales(t *testing.T) {
cols, rows := fitDimensions(4, 4, 40, 20)
if cols != 4 || rows != 2 {
t.Errorf("expected 4x2 (no upscale), got %dx%d", cols, rows)
}
}
func TestCompositeOpaquePassthrough(t *testing.T) {
c := color.RGBA{R: 1, G: 2, B: 3, A: 255}
got := composite(c, color.White)
if got != color.Color(c) {
t.Errorf("opaque color should pass through unchanged, got %v", got)
}
}
func TestCompositeTransparentOverBackground(t *testing.T) {
// Fully transparent pixel over red background should yield red.
got := composite(color.RGBA{}, color.RGBA{R: 255, A: 255})
r, g, b, a := got.RGBA()
if r>>8 != 255 || g>>8 != 0 || b>>8 != 0 || a != 0xffff {
t.Errorf("expected opaque red, got r=%d g=%d b=%d a=%d", r>>8, g>>8, b>>8, a)
}
}
+99 -2
View File
@@ -2,6 +2,7 @@ package ui
import (
"fmt"
"image/color"
"sort"
"strings"
@@ -13,6 +14,7 @@ import (
"github.com/mark3labs/kit/internal/clipboard"
"github.com/mark3labs/kit/internal/ui/commands"
"github.com/mark3labs/kit/internal/ui/core"
"github.com/mark3labs/kit/internal/ui/imagepreview"
"github.com/mark3labs/kit/internal/ui/style"
)
@@ -80,6 +82,23 @@ type InputComponent struct {
// Images are added via Ctrl+V and cleared on submit or Ctrl+U.
pendingImages []core.ImageAttachment
// imageThumbs caches the rendered half-block thumbnail for each entry in
// pendingImages (1:1 index correspondence). Thumbnails are rendered
// asynchronously off the Bubble Tea event loop (decode + resample is too
// slow to run inside Update), so an entry starts as the empty string
// placeholder and is filled in when the matching thumbnailReadyMsg
// arrives. An entry stays empty when the terminal cannot display a
// half-block preview, in which case the text pill is shown alone.
// See internal/ui/imagepreview.
imageThumbs []string
// imageGen is a monotonic generation counter incremented whenever the
// pending image set is cleared. Async thumbnail results carry the
// generation they were enqueued under and are discarded if it no longer
// matches, preventing a stale thumbnail from landing on the wrong slot
// after a clear + re-attach.
imageGen int
// history stores previously submitted prompts (most recent last).
// Limited to maxHistory entries; duplicates of the previous entry are
// skipped. Empty strings are never stored.
@@ -105,6 +124,16 @@ type clipboardImageMsg struct {
err error
}
// thumbnailReadyMsg carries the result of an async thumbnail render back to
// the Update loop. gen and index identify the pendingImages slot the
// thumbnail belongs to; the result is dropped if the generation no longer
// matches (the pending set was cleared) or the index is out of range.
type thumbnailReadyMsg struct {
gen int
index int
thumb string
}
// NewInputComponent creates a new InputComponent with the given width and
// optional AppController. If appCtrl is nil the component still works but
// /clear and /clear-queue are no-ops.
@@ -193,7 +222,23 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return s, nil
}
if msg.image != nil {
s.pendingImages = append(s.pendingImages, *msg.image)
img := *msg.image
index := len(s.pendingImages)
s.pendingImages = append(s.pendingImages, img)
// Reserve a placeholder; the async render fills it in via
// thumbnailReadyMsg so Update never blocks on decode/resample.
s.imageThumbs = append(s.imageThumbs, "")
cols := s.thumbCols()
if cols < 1 {
return s, nil
}
return s, renderThumbnailCmd(img, cols, thumbMaxRows, style.GetTheme().Background, s.imageGen, index)
}
return s, nil
case thumbnailReadyMsg:
if msg.gen == s.imageGen && msg.index >= 0 && msg.index < len(s.imageThumbs) {
s.imageThumbs[msg.index] = msg.thumb
}
return s, nil
@@ -250,6 +295,8 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Clear all pending image attachments.
if len(s.pendingImages) > 0 {
s.pendingImages = nil
s.imageThumbs = nil
s.imageGen++
return s, nil
}
}
@@ -486,6 +533,8 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
// images and clear them.
images := s.pendingImages
s.pendingImages = nil
s.imageThumbs = nil
s.imageGen++
return func() tea.Msg {
return core.SubmitMsg{Text: trimmed, Images: images}
}
@@ -519,6 +568,42 @@ func (s *InputComponent) resetHistoryBrowsing() {
s.savedInput = ""
}
// thumbMaxCols and thumbMaxRows cap the size, in terminal cells, of pending
// image previews. Kept small for the low-res look and to keep scrollback
// light.
const (
thumbMaxCols = 40
thumbMaxRows = 12
)
// thumbCols returns the thumbnail width in terminal cells given the current
// input width, or 0 when there is no room to render a preview.
func (s *InputComponent) thumbCols() int {
if s.width <= 6 {
return 0
}
cols := min(thumbMaxCols, s.width-6)
if cols < 1 {
return 0
}
return cols
}
// renderThumbnailCmd returns a tea.Cmd that renders a half-block ANSI preview
// off the Bubble Tea event loop. The decode + resample work runs in the Cmd
// goroutine, and the result is delivered as a thumbnailReadyMsg tagged with
// the generation and slot index it was enqueued for. An empty thumbnail
// (terminal unsupported or render error) leaves the text pill in place.
func renderThumbnailCmd(img core.ImageAttachment, cols, rows int, bg color.Color, gen, index int) tea.Cmd {
return func() tea.Msg {
thumb, err := imagepreview.Render(img.Data, img.MediaType, cols, rows, bg)
if err != nil {
thumb = ""
}
return thumbnailReadyMsg{gen: gen, index: index, thumb: thumb}
}
}
// View implements tea.Model. Renders the textarea, autocomplete popup
// (if visible), and help text.
func (s *InputComponent) View() tea.View {
@@ -544,7 +629,9 @@ func (s *InputComponent) View() tea.View {
// Popup is now rendered as a centered overlay in AppModel.View()
// instead of inline here to prevent bottom overflow
// Show image attachment indicator when images are pending.
// Show image attachment previews when images are pending. A cached
// half-block thumbnail is rendered when the terminal supports it;
// otherwise the text pill alone is shown.
if len(s.pendingImages) > 0 {
imgStyle := lipgloss.NewStyle().
Foreground(theme.Secondary).
@@ -553,6 +640,14 @@ func (s *InputComponent) View() tea.View {
label := fmt.Sprintf("[%d image(s) attached] ctrl+u to clear", len(s.pendingImages))
view.WriteString("\n")
view.WriteString(imgStyle.Render(label))
thumbStyle := lipgloss.NewStyle().PaddingLeft(3)
for i := range s.pendingImages {
if i < len(s.imageThumbs) && s.imageThumbs[i] != "" {
view.WriteString("\n")
view.WriteString(thumbStyle.Render(s.imageThumbs[i]))
}
}
}
if !s.hideHint {
@@ -844,6 +939,8 @@ func readClipboardImageCmd() tea.Cmd {
func (s *InputComponent) ClearPendingImages() []core.ImageAttachment {
images := s.pendingImages
s.pendingImages = nil
s.imageThumbs = nil
s.imageGen++
return images
}
+106
View File
@@ -25,6 +25,7 @@ import (
"github.com/mark3labs/kit/internal/ui/commands"
uicore "github.com/mark3labs/kit/internal/ui/core"
"github.com/mark3labs/kit/internal/ui/fileutil"
"github.com/mark3labs/kit/internal/ui/imagepreview"
"github.com/mark3labs/kit/internal/ui/prefs"
"github.com/mark3labs/kit/internal/ui/style"
kit "github.com/mark3labs/kit/pkg/kit"
@@ -1794,14 +1795,27 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// messages stay in chronological order.
m.pendingUserPrints = append(m.pendingUserPrints, displayText)
m.flushStreamAndPendingUserMessages()
// Insert inline thumbnail previews after the user message.
cmds = append(cmds, m.transcriptPreviewCmd(msg.Images, m.lastMessageID()))
}
} else {
m.printUserMessage(displayText)
// Insert inline thumbnail previews after the user message.
cmds = append(cmds, m.transcriptPreviewCmd(msg.Images, m.lastMessageID()))
}
if m.state != stateWorking {
m.state = stateWorking
}
// ── Async transcript image preview ───────────────────────────────────────
case imagePreviewReadyMsg:
if msg.block != "" {
item := NewStyledMessageItem(generateMessageID(), "user", "", msg.block)
m.insertMessageAfter(msg.anchorID, item)
m.refreshContent()
m.layoutDirty = true
}
// ── Shell command (! / !!) ───────────────────────────────────────────────
case uicore.ShellCommandMsg:
// Show spinner while the shell command runs.
@@ -2447,6 +2461,19 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.printSystemMessage(msg.Text)
}
// ── Clipboard image attached / thumbnail rendered ────────────────────────
// Both messages change the input region's rendered height (the pill and
// the async half-block preview), so forward them to the input and mark the
// layout dirty — otherwise distributeHeight keeps a stale, too-short input
// height and the preview is clipped off the bottom of the screen.
case clipboardImageMsg, thumbnailReadyMsg:
if m.input != nil {
updated, cmd := m.input.Update(msg)
m.input, _ = updated.(inputComponentIface)
cmds = append(cmds, cmd)
}
m.layoutDirty = true
default:
// Pass unrecognised messages to all children.
if m.input != nil {
@@ -3046,6 +3073,85 @@ func truncateMessageForBlock(msg string, maxLines, width int) string {
// Print helpers — add content to ScrollList
// --------------------------------------------------------------------------
// imagePreviewReadyMsg carries an asynchronously rendered transcript image
// preview block back to the Update loop, where it is inserted into the
// ScrollList directly after the originating user message (identified by
// anchorID). Inserting by anchor — rather than appending — keeps the preview
// next to its message even when the agent's streamed reply has already been
// appended while the thumbnail was being decoded off the event loop.
type imagePreviewReadyMsg struct {
block string
anchorID string
}
// transcriptPreviewCmd returns a tea.Cmd that renders half-block thumbnail
// previews for the given clipboard images off the Bubble Tea event loop
// (decode + resample must not block Update). The rendered block is delivered
// via imagePreviewReadyMsg, tagged with anchorID so the consumer can place it
// directly after the originating user message. Returns nil when there is
// nothing to render or no room for a preview; an empty result (terminal lacks
// color support) yields a nil message that Bubble Tea ignores.
func (m *AppModel) transcriptPreviewCmd(images []uicore.ImageAttachment, anchorID string) tea.Cmd {
if len(images) == 0 {
return nil
}
cols := thumbMaxCols
if m.width > 6 && m.width-6 < cols {
cols = m.width - 6
}
if cols < 1 {
return nil
}
bg := style.GetTheme().Background
imgs := images
return func() tea.Msg {
pad := lipgloss.NewStyle().PaddingLeft(2)
var blocks []string
for _, img := range imgs {
thumb, err := imagepreview.Render(img.Data, img.MediaType, cols, thumbMaxRows, bg)
if err != nil || thumb == "" {
continue
}
blocks = append(blocks, pad.Render(thumb))
}
if len(blocks) == 0 {
return nil
}
return imagePreviewReadyMsg{block: strings.Join(blocks, "\n"), anchorID: anchorID}
}
}
// lastMessageID returns the ID of the most recently added ScrollList message,
// or "" when there are none. Used to anchor an async transcript preview to the
// user message that was just printed.
func (m *AppModel) lastMessageID() string {
if len(m.messages) == 0 {
return ""
}
return m.messages[len(m.messages)-1].ID()
}
// insertMessageAfter inserts item immediately after the message whose ID
// matches anchorID. If anchorID is empty or not found, item is appended.
func (m *AppModel) insertMessageAfter(anchorID string, item MessageItem) {
idx := -1
if anchorID != "" {
for i, msgItem := range m.messages {
if msgItem.ID() == anchorID {
idx = i
break
}
}
}
if idx < 0 {
m.messages = append(m.messages, item)
return
}
m.messages = append(m.messages, nil)
copy(m.messages[idx+2:], m.messages[idx+1:])
m.messages[idx+1] = item
}
// printUserMessage renders a user message into the ScrollList.
func (m *AppModel) printUserMessage(text string) {
// Check if this exact message was just added (prevents duplicates)
@@ -0,0 +1,85 @@
package ui
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
uicore "github.com/mark3labs/kit/internal/ui/core"
)
// drainCmds runs a tea.Cmd chain back through m.Update like the BubbleTea
// event loop, expanding batches, until no further messages are produced.
func drainCmds(t *testing.T, m *AppModel, cmd tea.Cmd) *AppModel {
t.Helper()
queue := []tea.Cmd{cmd}
for i := 0; i < 50 && len(queue) > 0; i++ {
c := queue[0]
queue = queue[1:]
if c == nil {
continue
}
msg := c()
if msg == nil {
continue
}
if batch, ok := msg.(tea.BatchMsg); ok {
queue = append(queue, batch...)
continue
}
updated, nc := m.Update(msg)
m = updated.(*AppModel)
_ = m.View()
if nc != nil {
queue = append(queue, nc)
}
}
return m
}
func measuredInputHeight(m *AppModel) int {
rendered := m.renderInput()
if rendered == "" {
return 0
}
return strings.Count(rendered, "\n") + 1
}
// TestPendingThumbnailTriggersLayoutRecompute is a regression test for the bug
// where a pasted image's async half-block preview rendered but was clipped off
// the bottom of the screen: the thumbnail arrives via thumbnailReadyMsg after
// distributeHeight already measured the input region without it. The parent
// must mark the layout dirty so the (now taller) input is re-measured.
func TestPendingThumbnailTriggersLayoutRecompute(t *testing.T) {
// Force a truecolor profile so imagepreview.Render deterministically
// produces a thumbnail regardless of the CI terminal's color support.
// Without this, a low-color test environment yields an empty preview and
// the glyph / height assertions below would flake.
t.Setenv("TERM", "xterm-256color")
t.Setenv("COLORTERM", "truecolor")
t.Setenv("NO_COLOR", "")
real := NewInputComponent(80, nil)
m, _, _ := newTestAppModel(nil)
m.input = real
m = sendMsg(m, tea.WindowSizeMsg{Width: 80, Height: 24})
heightBefore := measuredInputHeight(m)
updated, cmd := m.Update(clipboardImageMsg{image: &uicore.ImageAttachment{
Data: makeTestPNG(t, 16, 16),
MediaType: "image/png",
}})
m = updated.(*AppModel)
_ = m.View()
m = drainCmds(t, m, cmd)
heightAfter := measuredInputHeight(m)
if heightAfter <= heightBefore {
t.Errorf("input region should grow to fit the thumbnail (before=%d after=%d)", heightBefore, heightAfter)
}
if !strings.Contains(m.View().Content, "▀") {
t.Error("parent View should contain the half-block thumbnail (was clipped or not rendered)")
}
}
+136
View File
@@ -0,0 +1,136 @@
package ui
import (
"bytes"
"image"
"image/color"
"image/png"
"strings"
"testing"
uicore "github.com/mark3labs/kit/internal/ui/core"
)
// makeTestPNG builds a small solid-color PNG for transcript preview tests.
func makeTestPNG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.Set(x, y, color.RGBA{R: 200, G: 40, B: 90, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
func TestTranscriptPreviewCmdNoImages(t *testing.T) {
m, _, _ := newTestAppModel(nil)
if cmd := m.transcriptPreviewCmd(nil, ""); cmd != nil {
t.Error("expected nil cmd when there are no images")
}
}
func TestTranscriptPreviewCmdRendersBlock(t *testing.T) {
m, _, _ := newTestAppModel(nil)
images := []uicore.ImageAttachment{
{Data: makeTestPNG(t, 16, 16), MediaType: "image/png"},
}
cmd := m.transcriptPreviewCmd(images, "anchor-1")
if cmd == nil {
t.Fatal("expected a non-nil cmd for a valid image")
}
msg := cmd()
// The result depends on the test process color profile. When the
// terminal supports color the cmd yields a preview block; otherwise it
// yields nil (caller keeps the text badge). Both are valid — assert the
// shape only when a block is produced.
if msg == nil {
t.Skip("color profile below ANSI256 in test env; preview correctly skipped")
}
ready, ok := msg.(imagePreviewReadyMsg)
if !ok {
t.Fatalf("expected imagePreviewReadyMsg, got %T", msg)
}
if !strings.Contains(ready.block, "▀") {
t.Errorf("preview block should contain half-block glyphs, got %q", ready.block)
}
if ready.anchorID != "anchor-1" {
t.Errorf("preview should carry the originating anchorID, got %q", ready.anchorID)
}
}
func TestImagePreviewReadyMsgAppendsItem(t *testing.T) {
m, _, _ := newTestAppModel(nil)
before := len(m.messages)
m = sendMsg(m, imagePreviewReadyMsg{block: "\x1b[38;2;1;2;3;48;2;4;5;6m▀\x1b[0m"})
if len(m.messages) != before+1 {
t.Fatalf("expected one appended message item, got %d (was %d)", len(m.messages), before)
}
last, ok := m.messages[len(m.messages)-1].(*TextMessageItem)
if !ok {
t.Fatalf("expected last item to be *TextMessageItem, got %T", m.messages[len(m.messages)-1])
}
if !strings.Contains(last.Render(0), "▀") {
t.Error("appended preview item should render the half-block block verbatim")
}
}
// TestImagePreviewReadyMsgInsertsAfterAnchor verifies the preview is placed
// directly after its originating user message even when a later message (e.g.
// a streamed assistant reply) was already appended while the thumbnail was
// being decoded asynchronously.
func TestImagePreviewReadyMsgInsertsAfterAnchor(t *testing.T) {
m, _, _ := newTestAppModel(nil)
userItem := NewStyledMessageItem("user-anchor", "user", "hi", "hi")
assistantItem := NewStyledMessageItem("assistant-1", "assistant", "reply", "reply")
m.messages = append(m.messages, userItem, assistantItem)
m = sendMsg(m, imagePreviewReadyMsg{
block: "\x1b[38;2;1;2;3;48;2;4;5;6m▀\x1b[0m",
anchorID: "user-anchor",
})
// Expect order: user, preview, assistant.
if len(m.messages) != 3 {
t.Fatalf("expected 3 messages, got %d", len(m.messages))
}
if m.messages[0].ID() != "user-anchor" {
t.Errorf("messages[0] should be the user message, got %q", m.messages[0].ID())
}
if m.messages[2].ID() != "assistant-1" {
t.Errorf("messages[2] should be the assistant message, got %q", m.messages[2].ID())
}
if !strings.Contains(m.messages[1].Render(0), "▀") {
t.Errorf("messages[1] should be the inserted preview, got %q", m.messages[1].Render(0))
}
}
// TestImagePreviewReadyMsgUnknownAnchorAppends verifies that when the anchor
// is missing (e.g. the message was cleared), the preview falls back to append.
func TestImagePreviewReadyMsgUnknownAnchorAppends(t *testing.T) {
m, _, _ := newTestAppModel(nil)
m.messages = append(m.messages, NewStyledMessageItem("only", "user", "hi", "hi"))
m = sendMsg(m, imagePreviewReadyMsg{
block: "\x1b[38;2;1;2;3;48;2;4;5;6m▀\x1b[0m",
anchorID: "does-not-exist",
})
if len(m.messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(m.messages))
}
if !strings.Contains(m.messages[1].Render(0), "▀") {
t.Error("preview should be appended as the last item when anchor is unknown")
}
}
func TestImagePreviewReadyMsgEmptyBlockIgnored(t *testing.T) {
m, _, _ := newTestAppModel(nil)
before := len(m.messages)
m = sendMsg(m, imagePreviewReadyMsg{block: ""})
if len(m.messages) != before {
t.Errorf("empty preview block should not append an item; got %d (was %d)", len(m.messages), before)
}
}
+43 -150
View File
@@ -571,67 +571,56 @@ func (eb *eventBus) emit(event Event) {
// Typed convenience subscribers
// ---------------------------------------------------------------------------
// subscribeTyped is the generic backbone of all the typed `On<EventName>`
// convenience methods on *Kit. It wraps Subscribe with a type assertion
// against E so handlers receive a strongly-typed event without each
// public method having to repeat the boilerplate. Returns an unsubscribe
// function.
func subscribeTyped[E Event](k *Kit, handler func(E)) func() {
return k.Subscribe(func(e Event) {
if tev, ok := e.(E); ok {
handler(tev)
}
})
}
// OnToolCall registers a handler that fires only for ToolCallEvent.
// Returns an unsubscribe function.
func (m *Kit) OnToolCall(handler func(ToolCallEvent)) func() {
return m.Subscribe(func(e Event) {
if tc, ok := e.(ToolCallEvent); ok {
handler(tc)
}
})
return subscribeTyped(m, handler)
}
// OnToolCallStart registers a handler that fires only for ToolCallStartEvent.
// This fires when the LLM begins generating tool call arguments — before the
// full argument JSON is available. Returns an unsubscribe function.
func (m *Kit) OnToolCallStart(handler func(ToolCallStartEvent)) func() {
return m.Subscribe(func(e Event) {
if tcs, ok := e.(ToolCallStartEvent); ok {
handler(tcs)
}
})
return subscribeTyped(m, handler)
}
// OnToolCallDelta registers a handler that fires only for ToolCallDeltaEvent.
// Each delta contains a JSON fragment of tool call arguments as they stream in.
// Returns an unsubscribe function.
func (m *Kit) OnToolCallDelta(handler func(ToolCallDeltaEvent)) func() {
return m.Subscribe(func(e Event) {
if tcd, ok := e.(ToolCallDeltaEvent); ok {
handler(tcd)
}
})
return subscribeTyped(m, handler)
}
// OnToolCallEnd registers a handler that fires only for ToolCallEndEvent.
// This fires when tool argument streaming is complete, before the tool call
// is parsed and execution begins. Returns an unsubscribe function.
func (m *Kit) OnToolCallEnd(handler func(ToolCallEndEvent)) func() {
return m.Subscribe(func(e Event) {
if tce, ok := e.(ToolCallEndEvent); ok {
handler(tce)
}
})
return subscribeTyped(m, handler)
}
// OnToolResult registers a handler that fires only for ToolResultEvent.
// Returns an unsubscribe function.
func (m *Kit) OnToolResult(handler func(ToolResultEvent)) func() {
return m.Subscribe(func(e Event) {
if tr, ok := e.(ToolResultEvent); ok {
handler(tr)
}
})
return subscribeTyped(m, handler)
}
// OnToolOutput registers a handler that fires only for ToolOutputEvent
// (streaming tool output chunks, e.g., from bash). Returns an unsubscribe function.
func (m *Kit) OnToolOutput(handler func(ToolOutputEvent)) func() {
return m.Subscribe(func(e Event) {
if to, ok := e.(ToolOutputEvent); ok {
handler(to)
}
})
return subscribeTyped(m, handler)
}
// OnStreaming registers a handler that fires only for MessageUpdateEvent
@@ -646,41 +635,25 @@ func (m *Kit) OnStreaming(handler func(MessageUpdateEvent)) func() {
// OnMessageUpdate registers a handler that fires only for MessageUpdateEvent
// (streaming text chunks). Returns an unsubscribe function.
func (m *Kit) OnMessageUpdate(handler func(MessageUpdateEvent)) func() {
return m.Subscribe(func(e Event) {
if mu, ok := e.(MessageUpdateEvent); ok {
handler(mu)
}
})
return subscribeTyped(m, handler)
}
// OnResponse registers a handler that fires only for ResponseEvent.
// Returns an unsubscribe function.
func (m *Kit) OnResponse(handler func(ResponseEvent)) func() {
return m.Subscribe(func(e Event) {
if r, ok := e.(ResponseEvent); ok {
handler(r)
}
})
return subscribeTyped(m, handler)
}
// OnTurnStart registers a handler that fires only for TurnStartEvent.
// Returns an unsubscribe function.
func (m *Kit) OnTurnStart(handler func(TurnStartEvent)) func() {
return m.Subscribe(func(e Event) {
if ts, ok := e.(TurnStartEvent); ok {
handler(ts)
}
})
return subscribeTyped(m, handler)
}
// OnTurnEnd registers a handler that fires only for TurnEndEvent.
// Returns an unsubscribe function.
func (m *Kit) OnTurnEnd(handler func(TurnEndEvent)) func() {
return m.Subscribe(func(e Event) {
if te, ok := e.(TurnEndEvent); ok {
handler(te)
}
})
return subscribeTyped(m, handler)
}
// ---------------------------------------------------------------------------
@@ -690,101 +663,61 @@ func (m *Kit) OnTurnEnd(handler func(TurnEndEvent)) func() {
// OnMessageStart registers a handler that fires only for MessageStartEvent.
// Returns an unsubscribe function.
func (m *Kit) OnMessageStart(handler func(MessageStartEvent)) func() {
return m.Subscribe(func(e Event) {
if ms, ok := e.(MessageStartEvent); ok {
handler(ms)
}
})
return subscribeTyped(m, handler)
}
// OnMessageEnd registers a handler that fires only for MessageEndEvent.
// Returns an unsubscribe function.
func (m *Kit) OnMessageEnd(handler func(MessageEndEvent)) func() {
return m.Subscribe(func(e Event) {
if me, ok := e.(MessageEndEvent); ok {
handler(me)
}
})
return subscribeTyped(m, handler)
}
// OnReasoningDelta registers a handler that fires only for ReasoningDeltaEvent.
// Returns an unsubscribe function.
func (m *Kit) OnReasoningDelta(handler func(ReasoningDeltaEvent)) func() {
return m.Subscribe(func(e Event) {
if rd, ok := e.(ReasoningDeltaEvent); ok {
handler(rd)
}
})
return subscribeTyped(m, handler)
}
// OnReasoningComplete registers a handler that fires only for ReasoningCompleteEvent.
// Returns an unsubscribe function.
func (m *Kit) OnReasoningComplete(handler func(ReasoningCompleteEvent)) func() {
return m.Subscribe(func(e Event) {
if rc, ok := e.(ReasoningCompleteEvent); ok {
handler(rc)
}
})
return subscribeTyped(m, handler)
}
// OnToolExecutionStart registers a handler that fires only for ToolExecutionStartEvent.
// Returns an unsubscribe function.
func (m *Kit) OnToolExecutionStart(handler func(ToolExecutionStartEvent)) func() {
return m.Subscribe(func(e Event) {
if tes, ok := e.(ToolExecutionStartEvent); ok {
handler(tes)
}
})
return subscribeTyped(m, handler)
}
// OnToolExecutionEnd registers a handler that fires only for ToolExecutionEndEvent.
// Returns an unsubscribe function.
func (m *Kit) OnToolExecutionEnd(handler func(ToolExecutionEndEvent)) func() {
return m.Subscribe(func(e Event) {
if tee, ok := e.(ToolExecutionEndEvent); ok {
handler(tee)
}
})
return subscribeTyped(m, handler)
}
// OnToolCallContent registers a handler that fires only for ToolCallContentEvent.
// Returns an unsubscribe function.
func (m *Kit) OnToolCallContent(handler func(ToolCallContentEvent)) func() {
return m.Subscribe(func(e Event) {
if tcc, ok := e.(ToolCallContentEvent); ok {
handler(tcc)
}
})
return subscribeTyped(m, handler)
}
// OnStepUsage registers a handler that fires only for StepUsageEvent.
// Returns an unsubscribe function.
func (m *Kit) OnStepUsage(handler func(StepUsageEvent)) func() {
return m.Subscribe(func(e Event) {
if su, ok := e.(StepUsageEvent); ok {
handler(su)
}
})
return subscribeTyped(m, handler)
}
// OnCompaction registers a handler that fires only for CompactionEvent.
// Returns an unsubscribe function.
func (m *Kit) OnCompaction(handler func(CompactionEvent)) func() {
return m.Subscribe(func(e Event) {
if ce, ok := e.(CompactionEvent); ok {
handler(ce)
}
})
return subscribeTyped(m, handler)
}
// OnSteerConsumed registers a handler that fires only for SteerConsumedEvent.
// Returns an unsubscribe function.
func (m *Kit) OnSteerConsumed(handler func(SteerConsumedEvent)) func() {
return m.Subscribe(func(e Event) {
if sc, ok := e.(SteerConsumedEvent); ok {
handler(sc)
}
})
return subscribeTyped(m, handler)
}
// ---------------------------------------------------------------------------
@@ -794,101 +727,61 @@ func (m *Kit) OnSteerConsumed(handler func(SteerConsumedEvent)) func() {
// OnStepStart registers a handler that fires only for StepStartEvent.
// Returns an unsubscribe function.
func (m *Kit) OnStepStart(handler func(StepStartEvent)) func() {
return m.Subscribe(func(e Event) {
if ss, ok := e.(StepStartEvent); ok {
handler(ss)
}
})
return subscribeTyped(m, handler)
}
// OnStepFinish registers a handler that fires only for StepFinishEvent.
// Returns an unsubscribe function.
func (m *Kit) OnStepFinish(handler func(StepFinishEvent)) func() {
return m.Subscribe(func(e Event) {
if sf, ok := e.(StepFinishEvent); ok {
handler(sf)
}
})
return subscribeTyped(m, handler)
}
// OnTextStart registers a handler that fires only for TextStartEvent.
// Returns an unsubscribe function.
func (m *Kit) OnTextStart(handler func(TextStartEvent)) func() {
return m.Subscribe(func(e Event) {
if ts, ok := e.(TextStartEvent); ok {
handler(ts)
}
})
return subscribeTyped(m, handler)
}
// OnTextEnd registers a handler that fires only for TextEndEvent.
// Returns an unsubscribe function.
func (m *Kit) OnTextEnd(handler func(TextEndEvent)) func() {
return m.Subscribe(func(e Event) {
if te, ok := e.(TextEndEvent); ok {
handler(te)
}
})
return subscribeTyped(m, handler)
}
// OnReasoningStart registers a handler that fires only for ReasoningStartEvent.
// Returns an unsubscribe function.
func (m *Kit) OnReasoningStart(handler func(ReasoningStartEvent)) func() {
return m.Subscribe(func(e Event) {
if rs, ok := e.(ReasoningStartEvent); ok {
handler(rs)
}
})
return subscribeTyped(m, handler)
}
// OnWarnings registers a handler that fires only for WarningsEvent.
// Returns an unsubscribe function.
func (m *Kit) OnWarnings(handler func(WarningsEvent)) func() {
return m.Subscribe(func(e Event) {
if w, ok := e.(WarningsEvent); ok {
handler(w)
}
})
return subscribeTyped(m, handler)
}
// OnSource registers a handler that fires only for SourceEvent.
// Returns an unsubscribe function.
func (m *Kit) OnSource(handler func(SourceEvent)) func() {
return m.Subscribe(func(e Event) {
if s, ok := e.(SourceEvent); ok {
handler(s)
}
})
return subscribeTyped(m, handler)
}
// OnStreamFinish registers a handler that fires only for StreamFinishEvent.
// Returns an unsubscribe function.
func (m *Kit) OnStreamFinish(handler func(StreamFinishEvent)) func() {
return m.Subscribe(func(e Event) {
if sf, ok := e.(StreamFinishEvent); ok {
handler(sf)
}
})
return subscribeTyped(m, handler)
}
// OnError registers a handler that fires only for ErrorEvent.
// Returns an unsubscribe function.
func (m *Kit) OnError(handler func(ErrorEvent)) func() {
return m.Subscribe(func(e Event) {
if ee, ok := e.(ErrorEvent); ok {
handler(ee)
}
})
return subscribeTyped(m, handler)
}
// OnRetry registers a handler that fires only for RetryEvent.
// Returns an unsubscribe function.
func (m *Kit) OnRetry(handler func(RetryEvent)) func() {
return m.Subscribe(func(e Event) {
if r, ok := e.(RetryEvent); ok {
handler(r)
}
})
return subscribeTyped(m, handler)
}
// ---------------------------------------------------------------------------
+29 -29
View File
@@ -155,17 +155,17 @@ func (m *Kit) Extensions() ExtensionAPI {
// Context management
func (e *extensionAPI) SetContext(ctx extensions.Context) {
func (e *extensionAPI) SetContext(ctx ExtensionContext) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetContext(ctx)
}
}
func (e *extensionAPI) GetContext() extensions.Context {
func (e *extensionAPI) GetContext() ExtensionContext {
if e.kit.extRunner != nil {
return e.kit.extRunner.GetContext()
}
return extensions.Context{}
return ExtensionContext{}
}
func (e *extensionAPI) UpdateContextModel(model string) {
@@ -178,7 +178,7 @@ func (e *extensionAPI) UpdateContextModel(model string) {
// Widgets
func (e *extensionAPI) SetWidget(config extensions.WidgetConfig) {
func (e *extensionAPI) SetWidget(config ExtensionWidgetConfig) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetWidget(config)
}
@@ -190,7 +190,7 @@ func (e *extensionAPI) RemoveWidget(id string) {
}
}
func (e *extensionAPI) GetWidgets(placement extensions.WidgetPlacement) []extensions.WidgetConfig {
func (e *extensionAPI) GetWidgets(placement ExtensionWidgetPlacement) []ExtensionWidgetConfig {
if e.kit.extRunner == nil {
return nil
}
@@ -199,7 +199,7 @@ func (e *extensionAPI) GetWidgets(placement extensions.WidgetPlacement) []extens
// Header/Footer
func (e *extensionAPI) SetHeader(config extensions.HeaderFooterConfig) {
func (e *extensionAPI) SetHeader(config ExtensionHeaderFooterConfig) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetHeader(config)
}
@@ -211,14 +211,14 @@ func (e *extensionAPI) RemoveHeader() {
}
}
func (e *extensionAPI) GetHeader() *extensions.HeaderFooterConfig {
func (e *extensionAPI) GetHeader() *ExtensionHeaderFooterConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetHeader()
}
func (e *extensionAPI) SetFooter(config extensions.HeaderFooterConfig) {
func (e *extensionAPI) SetFooter(config ExtensionHeaderFooterConfig) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetFooter(config)
}
@@ -230,7 +230,7 @@ func (e *extensionAPI) RemoveFooter() {
}
}
func (e *extensionAPI) GetFooter() *extensions.HeaderFooterConfig {
func (e *extensionAPI) GetFooter() *ExtensionHeaderFooterConfig {
if e.kit.extRunner == nil {
return nil
}
@@ -239,7 +239,7 @@ func (e *extensionAPI) GetFooter() *extensions.HeaderFooterConfig {
// Editor
func (e *extensionAPI) SetEditor(config extensions.EditorConfig) {
func (e *extensionAPI) SetEditor(config ExtensionEditorConfig) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetEditor(config)
}
@@ -251,7 +251,7 @@ func (e *extensionAPI) ResetEditor() {
}
}
func (e *extensionAPI) GetEditor() *extensions.EditorConfig {
func (e *extensionAPI) GetEditor() *ExtensionEditorConfig {
if e.kit.extRunner == nil {
return nil
}
@@ -260,13 +260,13 @@ func (e *extensionAPI) GetEditor() *extensions.EditorConfig {
// UI Visibility
func (e *extensionAPI) SetUIVisibility(v extensions.UIVisibility) {
func (e *extensionAPI) SetUIVisibility(v ExtensionUIVisibility) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetUIVisibility(v)
}
}
func (e *extensionAPI) GetUIVisibility() *extensions.UIVisibility {
func (e *extensionAPI) GetUIVisibility() *ExtensionUIVisibility {
if e.kit.extRunner == nil {
return nil
}
@@ -275,14 +275,14 @@ func (e *extensionAPI) GetUIVisibility() *extensions.UIVisibility {
// Tool rendering
func (e *extensionAPI) GetToolRenderer(toolName string) *extensions.ToolRenderConfig {
func (e *extensionAPI) GetToolRenderer(toolName string) *ExtensionToolRenderConfig {
if e.kit.extRunner == nil {
return nil
}
return e.kit.extRunner.GetToolRenderer(toolName)
}
func (e *extensionAPI) GetMessageRenderer(name string) *extensions.MessageRendererConfig {
func (e *extensionAPI) GetMessageRenderer(name string) *ExtensionMessageRendererConfig {
if e.kit.extRunner == nil {
return nil
}
@@ -291,7 +291,7 @@ func (e *extensionAPI) GetMessageRenderer(name string) *extensions.MessageRender
// Session data
func (e *extensionAPI) GetSessionMessages() []extensions.SessionMessage {
func (e *extensionAPI) GetSessionMessages() []ExtensionSessionMessage {
if e.kit.session == nil {
return nil
}
@@ -299,8 +299,8 @@ func (e *extensionAPI) GetSessionMessages() []extensions.SessionMessage {
// Try to use the legacy iterBranchMessages for backward compatibility
// with the default TreeManager adapter
if adapter, ok := e.kit.session.(*treeManagerAdapter); ok {
return iterBranchMessages(adapter.inner, func(me *session.MessageEntry, msg message.Message) extensions.SessionMessage {
return extensions.SessionMessage{
return iterBranchMessages(adapter.inner, func(me *session.MessageEntry, msg message.Message) ExtensionSessionMessage {
return ExtensionSessionMessage{
ID: me.ID,
Role: string(msg.Role),
Content: msg.Content(),
@@ -311,10 +311,10 @@ func (e *extensionAPI) GetSessionMessages() []extensions.SessionMessage {
// For custom SessionManagers, use the public interface
branch := e.kit.session.GetCurrentBranch()
var result []extensions.SessionMessage
var result []ExtensionSessionMessage
for _, entry := range branch {
if entry.Type == EntryTypeMessage {
result = append(result, extensions.SessionMessage{
result = append(result, ExtensionSessionMessage{
ID: entry.ID,
Role: entry.Role,
Content: entry.Content,
@@ -332,14 +332,14 @@ func (e *extensionAPI) AppendEntry(extType, data string) (string, error) {
return e.kit.session.AppendExtensionData(extType, data)
}
func (e *extensionAPI) GetEntries(extType string) []extensions.ExtensionEntry {
func (e *extensionAPI) GetEntries(extType string) []ExtensionEntry {
if e.kit.session == nil {
return nil
}
entries := e.kit.session.GetExtensionData(extType)
result := make([]extensions.ExtensionEntry, 0, len(entries))
result := make([]ExtensionEntry, 0, len(entries))
for _, e := range entries {
result = append(result, extensions.ExtensionEntry{
result = append(result, ExtensionEntry{
ID: e.ID,
EntryType: e.ExtType,
Data: e.Data,
@@ -351,7 +351,7 @@ func (e *extensionAPI) GetEntries(extType string) []extensions.ExtensionEntry {
// Status bar
func (e *extensionAPI) SetStatus(entry extensions.StatusBarEntry) {
func (e *extensionAPI) SetStatus(entry ExtensionStatusBarEntry) {
if e.kit.extRunner != nil {
e.kit.extRunner.SetStatusEntry(entry)
}
@@ -363,7 +363,7 @@ func (e *extensionAPI) RemoveStatus(key string) {
}
}
func (e *extensionAPI) GetStatusEntries() []extensions.StatusBarEntry {
func (e *extensionAPI) GetStatusEntries() []ExtensionStatusBarEntry {
if e.kit.extRunner == nil {
return nil
}
@@ -394,12 +394,12 @@ func (e *extensionAPI) GetShortcuts() map[string]func() {
// Tools
func (e *extensionAPI) GetToolInfos() []extensions.ToolInfo {
func (e *extensionAPI) GetToolInfos() []ExtensionToolInfo {
agentTools := e.kit.agent.GetTools()
coreCount := e.kit.agent.GetCoreToolCount()
mcpCount := e.kit.agent.GetMCPToolCount()
result := make([]extensions.ToolInfo, 0, len(agentTools))
result := make([]ExtensionToolInfo, 0, len(agentTools))
for i, t := range agentTools {
info := t.Info()
source := "core"
@@ -412,7 +412,7 @@ func (e *extensionAPI) GetToolInfos() []extensions.ToolInfo {
if e.kit.extRunner != nil && e.kit.extRunner.IsToolDisabled(info.Name) {
enabled = false
}
result = append(result, extensions.ToolInfo{
result = append(result, ExtensionToolInfo{
Name: info.Name,
Description: info.Description,
Source: source,
@@ -505,7 +505,7 @@ func (e *extensionAPI) EmitBeforeSessionSwitch(switchReason string) (cancelled b
// Commands
func (e *extensionAPI) Commands() []extensions.CommandDef {
func (e *extensionAPI) Commands() []ExtensionCommandDef {
if e.kit.extRunner == nil {
return nil
}
+20
View File
@@ -61,3 +61,23 @@ func CheckProviderReady(provider string) error {
}
return models.GetGlobalRegistry().ValidateEnvironment(provider, "")
}
// ResolveProviderBaseURL returns the base API URL kit will use when talking to
// the given provider, applying the same resolution order that CreateProvider
// uses internally:
//
// 1. The provider's `api` field from the models.dev registry.
// 2. The hard-coded default base URL of its npm SDK package (e.g.
// @ai-sdk/groq → https://api.groq.com/openai/v1).
// 3. Template substitution against the current process environment when the
// URL contains "${VAR}" placeholders.
//
// Returns a non-nil error when the provider is unknown, when no URL can be
// derived, or when a templated URL has unset placeholders.
//
// Use this from your SDK integration to surface the effective endpoint before
// instantiating a Kit, or to validate that a provider is reachable without
// running an actual request.
func ResolveProviderBaseURL(providerID string) (string, error) {
return models.ResolveProviderBaseURL(providerID)
}
+17
View File
@@ -110,6 +110,23 @@ Press **Ctrl+X s** during streaming to inject a system-level instruction mid-tur
Example: While the model is writing code, press Ctrl+X s and type "Use async/await instead" to change the implementation approach.
### Image attachments
Attach images to your next prompt straight from the clipboard:
- Copy an image (e.g. a screenshot) to the system clipboard, then press **Ctrl+V** in the input to attach it.
- Press **Ctrl+U** to clear all pending image attachments.
- Attachments are sent alongside your text when you submit, and cleared afterward.
When a terminal supports color, Kit renders a small low-resolution **thumbnail preview** of each pending image directly in the input, below the `[N image(s) attached]` indicator, so you can confirm the right image was attached before sending.
The preview is drawn with Unicode half-block characters and ordinary terminal colors — not a graphics protocol — so it renders correctly inside terminal multiplexers like **tmux** and **zellij**. Thumbnails are capped to a small cell box for a glanceable, low-res look.
- Best fidelity needs a **truecolor** terminal (`COLORTERM=truecolor`); Kit degrades to 256-color where truecolor is unavailable.
- On terminals with neither, the preview is skipped and the `[N image(s) attached]` text indicator is shown alone.
You can also attach image files by referencing them with `@path/to/image.png` — binary files are auto-detected by MIME type. See [Quick Start](/quick-start) for the `@` attachment syntax.
## Prompt templates
### Creating templates
+33
View File
@@ -143,6 +143,39 @@ The `custom/custom` model has zero cost, 262K context window, and supports reaso
Optionally set `CUSTOM_API_KEY` environment variable or use `--provider-api-key` for endpoints requiring authentication.
## Auto-routed providers
Any provider in the [models.dev](https://models.dev) database can be used with the
standard `provider/model` format, even without a dedicated native integration. Kit
auto-routes the request through the matching **wire protocol** — the actual API
shape the provider speaks — rather than requiring a per-provider code path:
| Wire protocol | npm package (models.dev) | Transport used |
|---------------|--------------------------|----------------|
| OpenAI (Responses API) | `@ai-sdk/openai` | OpenAI |
| OpenAI (chat completions) | `@ai-sdk/openai-compatible` | OpenAI-compatible |
| Anthropic | `@ai-sdk/anthropic` | Anthropic |
| Google Gemini | `@ai-sdk/google` | Google |
The provider's `api` URL from the database is used as the base URL. A provider
whose npm package isn't recognized but that has an `api` URL falls back to the
OpenAI-compatible wire.
Because routing follows the wire protocol, aggregator/proxy providers work across
**all** of their models — including ones they re-flavor onto a different protocol
via a per-model override. For example, an aggregator that proxies Claude, GPT,
*and* Gemini routes them to the Anthropic, OpenAI, and Google transports
respectively:
```bash
kit --model opencode/claude-haiku-4-5 "Hello" # → Anthropic wire
kit --model opencode/gpt-5 "Hello" # → OpenAI wire
kit --model opencode/gemini-3.5-flash "Hello" # → Google wire
```
Provide the provider's API key the same way as any other — via its environment
variable (e.g. `OPENCODE_API_KEY`) or `--provider-api-key`.
## Model database
Kit ships with a local model database that maps provider names to API configurations. You can manage it with: