Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 52 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ alone; one that exists without it is reported rather than overwritten. If the
daemon step fails, the provider and agent are already saved and setup says so
instead of unwinding.

To do it by hand instead, add an OpenAI-compatible provider and then the global
chat agent:
To do it by hand instead, add a provider and then the global chat agent. The
default provider kind is an OpenAI-compatible Chat Completions endpoint:

```sh
./out/mininaru provider add \
Expand All @@ -184,6 +184,45 @@ chat agent:

The first agent becomes the global agent used by the interactive client.

Native Anthropic Messages API providers use `--kind anthropic`. Its base URL is
the API origin, without the OpenAI-style `/v1` suffix:

```sh
mininaru provider add \
--name anthropic \
--kind anthropic \
--base-url https://api.anthropic.com \
--api-key '<API_KEY>' \
--cache ephemeral

mininaru agent add --name claude --provider anthropic --model claude-sonnet-4-6
```

`--cache` accepts `auto`, `off`, `ephemeral`, or `ephemeral_1h`. `auto` leaves
OpenAI and other provider-native automatic caches alone, enables Anthropic's
automatic five-minute cache, and adds the same cache control for Claude models
routed through OpenRouter. The explicit ephemeral modes add top-level
`cache_control`; use `ephemeral_1h` only when the longer, more expensive cache
write is worthwhile.

OpenRouter can also cache an entire identical API response. This is separate
from prompt caching and is deliberately opt-in because it can return a previous
answer without running the model:

```sh
mininaru provider add \
--name openrouter \
--base-url https://openrouter.ai/api/v1 \
--api-key '<API_KEY>' \
--cache auto \
--response-cache \
--response-cache-ttl 300
```

Response caching stores request/response data temporarily and is unavailable
with OpenRouter account-level Zero Data Retention. Keep it off for sensitive or
time-dependent conversations.

## Interactive prompts

The commands that create or change something -- `provider add`, `provider
Expand Down Expand Up @@ -322,11 +361,11 @@ Every model call made on a session's behalf is recorded against it, so
total down by what spent it. `/usage` reports the running total inside the TUI.

```
KIND PROMPT CACHED COMPLETION TOTAL
turn 120,411 94,208 8,204 128,615
compaction 6,890 4,096 412 7,302
subagent 31,204 24,576 2,110 33,314
total 158,505 122,880 10,726 169,231
KIND PROMPT CACHE READ CACHE WRITE COMPLETION TOTAL
turn 120,411 94,208 12,000 8,204 128,615
compaction 6,890 4,096 1,024 412 7,302
subagent 31,204 24,576 2,048 2,110 33,314
total 158,505 122,880 15,072 10,726 169,231
```

`turn` is the answers you asked for, `compaction` is the summarising above, and
Expand All @@ -338,12 +377,12 @@ one, and each is billed, so its total is the sum of every round rather than the
last. The same applies to what the HTTP API reports back: the `usage` in a
response covers every round the server ran on the caller's behalf.

`CACHED` is the subset of prompt tokens the provider reports as cache hits. The
provider or inference server owns the actual prompt cache; mininaru keeps tool
definitions in a stable order so the shared prefix stays reusable, and records
`prompt_tokens_details.cached_tokens` when the OpenAI-compatible endpoint returns
it. A blank or zero value means the provider did not report a hit, not that
mininaru maintains a second local model cache.
`CACHE READ` is the subset of prompt tokens served from a provider cache, while
`CACHE WRITE` is the number written into a new cache entry. OpenAI-compatible
providers report these as `cached_tokens` and `cache_write_tokens`; Anthropic
reports `cache_read_input_tokens` and `cache_creation_input_tokens`. A zero
means the provider reported no cache activity (or the prompt was shorter than
that model's cache minimum), not that mininaru maintains a second local cache.

**These are tokens, not money.** mininaru talks to whatever provider you point it
at and has no idea what yours charges, so the conversion is yours to do. Usage
Expand Down
68 changes: 49 additions & 19 deletions cli/preference.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ var (
providerNameRef string
providerApiKeyRef string
providerBaseURLRef string
providerKindRef string
providerCacheRef string
providerRespCache bool
providerRespTTL int

agentNameRef string
agentRoleRef string
Expand All @@ -29,10 +33,10 @@ var (
var provider *cobra.Command = &cobra.Command{
Use: "provider",
Short: "manage LLM providers",
Long: `Manage the OpenAI compatible endpoints agents talk to.
Long: `Manage the OpenAI-compatible or native Anthropic endpoints agents talk to.

A provider bundles a base URL and an API key. Agents pick a provider when they
are created, and new agents fall back to the default one.`,
A provider bundles an API kind, base URL, API key, and cache policy. Agents pick
a provider when they are created, and new agents fall back to the default one.`,
Example: ` mininaru provider add --name openai --base-url https://api.openai.com/v1
mininaru provider list
mininaru provider default openai`,
Expand Down Expand Up @@ -245,9 +249,12 @@ func providerAddExecute(cmd *cobra.Command, args []string) error {
}

payload = core.Provider{
Name: providerNameRef,
ApiKey: providerApiKeyRef,
BaseURL: providerBaseURLRef,
Name: providerNameRef, ApiKey: providerApiKeyRef, BaseURL: providerBaseURLRef,
Kind: providerKindRef, Cache: providerCacheRef, ResponseCache: providerRespCache, ResponseCacheTTL: providerRespTTL,
}
err = core.ProviderValidate(payload)
if err != nil {
return err
}

core.ProviderCreate(payload)
Expand Down Expand Up @@ -278,15 +285,15 @@ func providerListExecute(cmd *cobra.Command, args []string) error {
return nil
}

rows = uiTable("ID", "NAME", "BASE URL", "API KEY", "")
rows = uiTable("ID", "NAME", "KIND", "CACHE", "RESPONSE CACHE", "BASE URL", "API KEY", "")

for _, cur = range core.Providers {
mark = ""
if cur == core.DefaultProvider {
mark = "[default]"
}

rows.row(cur.Id, cur.Name, cur.BaseURL, maskSecret(cur.ApiKey), mark)
rows.row(cur.Id, cur.Name, cur.ProviderKind(), cur.CachePolicy(), strconv.FormatBool(cur.ResponseCache), cur.BaseURL, maskSecret(cur.ApiKey), mark)
}

rows.flush()
Expand Down Expand Up @@ -338,18 +345,21 @@ func providerUpdateAsk(current *core.Provider) error {
func providerUpdateExecute(cmd *cobra.Command, args []string) error {
var touched bool
var current *core.Provider
var name, apiKey, baseURL *string
var name, apiKey, baseURL, kind, cache *string
var responseCache *bool
var responseTTL *int

var err error

touched = cmd.Flags().Changed("name") || cmd.Flags().Changed("api-key") || cmd.Flags().Changed("base-url")
current, err = core.ProviderFind(args[0])
if err != nil {
return err
}

if !touched && askInteractive() {
current, err = core.ProviderFind(args[0])
if err != nil {
return err
}
touched = cmd.Flags().Changed("name") || cmd.Flags().Changed("api-key") || cmd.Flags().Changed("base-url") ||
cmd.Flags().Changed("kind") || cmd.Flags().Changed("cache") || cmd.Flags().Changed("response-cache") || cmd.Flags().Changed("response-cache-ttl")

if !touched && askInteractive() {
err = providerUpdateAsk(current)
if err != nil {
return err
Expand All @@ -371,8 +381,20 @@ func providerUpdateExecute(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("base-url") {
baseURL = &providerBaseURLRef
}
if cmd.Flags().Changed("kind") {
kind = &providerKindRef
}
if cmd.Flags().Changed("cache") {
cache = &providerCacheRef
}
if cmd.Flags().Changed("response-cache") {
responseCache = &providerRespCache
}
if cmd.Flags().Changed("response-cache-ttl") {
responseTTL = &providerRespTTL
}

return core.ProviderUpdateFields(args[0], name, apiKey, baseURL)
return core.ProviderUpdateConfig(current.Id, name, apiKey, baseURL, kind, cache, responseCache, responseTTL)
}

func providerRemoveExecute(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -815,14 +837,14 @@ func sessionUsageExecute(cmd *cobra.Command, args []string) error {
return nil
}

rows = uiTable("KIND", "PROMPT", "CACHED", "COMPLETION", "TOTAL")
rows = uiTable("KIND", "PROMPT", "CACHE READ", "CACHE WRITE", "COMPLETION", "TOTAL")

for _, line = range totals.Lines {
rows.row(line.Kind, tokenCount(line.PromptTokens), tokenCount(line.CachedTokens), tokenCount(line.CompletionTokens),
rows.row(line.Kind, tokenCount(line.PromptTokens), tokenCount(line.CachedTokens), tokenCount(line.CacheWriteTokens), tokenCount(line.CompletionTokens),
tokenCount(line.TotalTokens))
}

rows.row("total", tokenCount(totals.PromptTokens), tokenCount(totals.CachedTokens), tokenCount(totals.CompletionTokens),
rows.row("total", tokenCount(totals.PromptTokens), tokenCount(totals.CachedTokens), tokenCount(totals.CacheWriteTokens), tokenCount(totals.CompletionTokens),
tokenCount(totals.TotalTokens))
rows.flush()

Expand Down Expand Up @@ -873,10 +895,18 @@ func init() {
providerAdd.Flags().StringVarP(&providerNameRef, "name", "n", "", "provider name")
providerAdd.Flags().StringVarP(&providerApiKeyRef, "api-key", "k", "", "provider api key")
providerAdd.Flags().StringVarP(&providerBaseURLRef, "base-url", "b", "", "provider base url")
providerAdd.Flags().StringVar(&providerKindRef, "kind", core.ProviderOpenAI, "provider API kind (openai or anthropic)")
providerAdd.Flags().StringVar(&providerCacheRef, "cache", core.CacheAuto, "prompt cache policy (auto, off, ephemeral, or ephemeral_1h)")
providerAdd.Flags().BoolVar(&providerRespCache, "response-cache", false, "enable OpenRouter whole-response caching")
providerAdd.Flags().IntVar(&providerRespTTL, "response-cache-ttl", 0, "OpenRouter response cache TTL in seconds")

providerUpdate.Flags().StringVarP(&providerNameRef, "name", "n", "", "provider name")
providerUpdate.Flags().StringVarP(&providerApiKeyRef, "api-key", "k", "", "provider api key")
providerUpdate.Flags().StringVarP(&providerBaseURLRef, "base-url", "b", "", "provider base url")
providerUpdate.Flags().StringVar(&providerKindRef, "kind", "", "provider API kind (openai or anthropic)")
providerUpdate.Flags().StringVar(&providerCacheRef, "cache", "", "prompt cache policy (auto, off, ephemeral, or ephemeral_1h)")
providerUpdate.Flags().BoolVar(&providerRespCache, "response-cache", false, "enable OpenRouter whole-response caching")
providerUpdate.Flags().IntVar(&providerRespTTL, "response-cache-ttl", 0, "OpenRouter response cache TTL in seconds")

provider.AddCommand(providerAdd, providerList, providerUpdate, providerRemove, providerDefault)

Expand Down
4 changes: 2 additions & 2 deletions cli/tui/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -625,8 +625,8 @@ func (c *client) usageCommand() tea.Cmd {
return nil
}

notice = fmt.Sprintf("%d tokens this session (%d prompt, %d completion, %d cached)",
totals.TotalTokens, totals.PromptTokens, totals.CompletionTokens, totals.CachedTokens)
notice = fmt.Sprintf("%d tokens this session (%d prompt, %d completion, %d cache read, %d cache write)",
totals.TotalTokens, totals.PromptTokens, totals.CompletionTokens, totals.CachedTokens, totals.CacheWriteTokens)

if totals.TotalTokens == 0 {
notice = "no token usage recorded for this session yet"
Expand Down
66 changes: 54 additions & 12 deletions core/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import (
"sync"
"time"

"github.com/anthropics/anthropic-sdk-go"
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
"github.com/devproje/mininaru/util"
"github.com/google/uuid"
"github.com/openai/openai-go"
"github.com/openai/openai-go/option"
openaioption "github.com/openai/openai-go/option"
)

type NaruAgent struct {
Expand All @@ -29,7 +31,8 @@ type NaruAgent struct {
Model string `json:"model"`
ProviderId string `json:"provider_id"`

AI *openai.Client `json:"-"`
AI *openai.Client `json:"-"`
Anthropic *anthropic.Client `json:"-"`
}

var modelContextWindows sync.Map
Expand Down Expand Up @@ -92,7 +95,7 @@ func (a *NaruAgent) ModelContextWindow(ctx context.Context) int64 {
return 0
}
provider, err = ProviderFind(a.ProviderId)
if err != nil || provider.BaseURL == "" {
if err != nil || provider.BaseURL == "" || provider.ProviderKind() == ProviderAnthropic {
return 0
}
cacheKey = a.modelContextCacheKey()
Expand Down Expand Up @@ -152,26 +155,66 @@ var Agents []*NaruAgent
var emptyAgentObj AgentConfig = AgentConfig{}

func newClient(prov *Provider) *openai.Client {
var opts []option.RequestOption
var opts []openaioption.RequestOption
var ai openai.Client

if prov == nil {
return nil
}

if prov.ApiKey != "" {
opts = append(opts, option.WithAPIKey(prov.ApiKey))
opts = append(opts, openaioption.WithAPIKey(prov.ApiKey))
}

if prov.BaseURL != "" {
opts = append(opts, option.WithBaseURL(prov.BaseURL))
opts = append(opts, openaioption.WithBaseURL(prov.BaseURL))
}

if prov.ResponseCache && isOpenRouter(prov.BaseURL) {
opts = append(opts, openaioption.WithHeader("X-OpenRouter-Cache", "true"))
if prov.ResponseCacheTTL > 0 {
opts = append(opts, openaioption.WithHeader("X-OpenRouter-Cache-TTL", fmt.Sprintf("%d", prov.ResponseCacheTTL)))
}
}

ai = openai.NewClient(opts...)

return &ai
}

func newAnthropicClient(prov *Provider) *anthropic.Client {
var opts []anthropicoption.RequestOption
var ai anthropic.Client

if prov == nil || prov.ProviderKind() != ProviderAnthropic {
return nil
}
if prov.ApiKey != "" {
opts = append(opts, anthropicoption.WithAPIKey(prov.ApiKey))
}
if prov.BaseURL != "" {
opts = append(opts, anthropicoption.WithBaseURL(prov.BaseURL))
}

ai = anthropic.NewClient(opts...)
return &ai
}

func configureAgentClients(agent *NaruAgent, prov *Provider) {
if agent == nil {
return
}

if prov != nil && prov.ProviderKind() == ProviderAnthropic {
agent.AI = nil
agent.Anthropic = newAnthropicClient(prov)
return
}

agent.AI = newClient(prov)
agent.Anthropic = nil
}

func AgentNew(name, role, soul, model string, prov *Provider) *NaruAgent {
var agent NaruAgent

Expand All @@ -186,9 +229,8 @@ func AgentNew(name, role, soul, model string, prov *Provider) *NaruAgent {
Soul: soul,
Model: model,
ProviderId: prov.Id,

AI: newClient(prov),
}
configureAgentClients(&agent, prov)

return &agent
}
Expand Down Expand Up @@ -238,11 +280,11 @@ func AgentInit() error {
Agents = cfg.Agents

if Global != nil {
Global.AI = newClient(agentProvider(Global))
configureAgentClients(Global, agentProvider(Global))
}

for _, cur = range Agents {
cur.AI = newClient(agentProvider(cur))
configureAgentClients(cur, agentProvider(cur))
}

return nil
Expand Down Expand Up @@ -412,7 +454,7 @@ func AgentRefreshClient(agent *NaruAgent) error {
return err
}

agent.AI = newClient(prov)
configureAgentClients(agent, prov)

return nil
}
Expand Down Expand Up @@ -449,7 +491,7 @@ func AgentUpdateFields(id string, name, role, soul, model, providerId *string) e

if providerId != nil {
update.ProviderId = *providerId
update.AI = newClient(agentProvider(&update))
configureAgentClients(&update, agentProvider(&update))
}

Agents[index] = &update
Expand Down
Loading