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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Generated files — collapse in GitHub diffs by default and skip from
# linguist language stats. Regenerate with `make sync-models`.
internal/providers/models_generated.json linguist-generated=true
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ check: vet test typecheck web-test ## Run every check (add `make smoke` for the
fmt: ## Format Go sources
@$(GO) fmt ./...

.PHONY: sync-models
sync-models: ## Refresh internal/providers/models_generated.json from models.dev
@$(GO) run ./scripts/sync-models-dev.go

.PHONY: release
release: ## Cross-compile release binaries for all platforms into dist/release
@VERSION="$(VERSION)" GO="$(GO)" BUN="$(BUN)" ./scripts/release-build.sh
Expand Down
7 changes: 7 additions & 0 deletions cmd/antares/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,13 @@ func bootstrap(ctx context.Context) (*runtimeServices, error) {
if err := logx.Setup(cfg.Logging.Level, cfg.Logging.File, cfg.Logging.JSON); err != nil {
return nil, fmt.Errorf("setting up logging: %w", err)
}
// Kick off the background models.dev refresh. The bundled snapshot
// answers every lookup immediately (offline, first-run, corporate
// firewall); this pulls a fresh copy in the background and caches it
// under $XDG_CACHE_HOME/antares/models.json for the next boot. Set
// ANTARES_DISABLE_MODELS_FETCH=1 to skip on airgapped hosts.
providers.StartRefresh(ctx)

// Don't create the default workspace before the wizard has run — a fresh
// install shouldn't leave ~/antares-workspace behind if setup is abandoned.
if !needsSetup(cfg) {
Expand Down
77 changes: 69 additions & 8 deletions internal/agent/compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"unicode/utf8"

"github.com/enowdev/antares/internal/config"
"github.com/enowdev/antares/internal/llm"
"github.com/enowdev/antares/internal/providers"
"github.com/enowdev/antares/internal/store"
Expand All @@ -21,21 +22,81 @@ import (
// sane fallback. It mirrors the window maybeCompact governs, so the UI's
// "context full" bar agrees with compaction.
func (a *Agent) contextWindowFor(model string) int {
if a.config() != nil {
for _, p := range a.config().Providers {
if m, ok := p.ModelMeta[model]; ok && m.ContextWindow > 0 {
return m.ContextWindow
}
cfg := a.config()
if cfg == nil || model == "" {
return 128000
}
candidates := candidateProvidersForModel(cfg, model)
// First pass: strict provider/id lookup.
for _, providerID := range candidates {
p := cfg.Providers[providerID]
if m, ok := p.ModelMeta[model]; ok && m.ContextWindow > 0 {
return m.ContextWindow
}
if meta, ok := providers.MetaByProvider(providerID, model); ok && meta.ContextWindow > 0 {
return meta.ContextWindow
}
}
if w := providers.ContextWindow(model); w > 0 {
// Second pass: proxy fallback for openai-compatible providers that
// re-serve official models under real ids.
for _, providerID := range candidates {
kind := cfg.Providers[providerID].Kind
if kind != "openai-compatible" && kind != "custom" {
continue
}
if meta, ok := providers.MetaByAnyProvider(model); ok && meta.ContextWindow > 0 {
return meta.ContextWindow
}
}
if w := legacyCuratedWindow(model); w > 0 {
return w
}
if a.config() != nil && a.config().Model.ContextWindow > 0 {
return a.config().Model.ContextWindow
if cfg.Model.ContextWindow > 0 {
return cfg.Model.ContextWindow
}
return 128000
}
// candidateProvidersForModel mirrors the server's candidateProviders: active
// provider first, then any provider whose Models list *or* ModelMeta keys
// mention the id. A provider that carries a per-model meta entry has clearly
// claimed the model even if it never added the id to Models.
func candidateProvidersForModel(cfg *config.Config, model string) []string {
seen := map[string]bool{}
var out []string
push := func(id string) {
if id == "" || seen[id] {
return
}
seen[id] = true
out = append(out, id)
}
push(cfg.Model.Provider)
for providerID, p := range cfg.Providers {
if _, ok := p.ModelMeta[model]; ok {
push(providerID)
continue
}
for _, m := range p.Models {
if m == model {
push(providerID)
break
}
}
}
return out
}

// legacyCuratedWindow reaches into providers.ContextWindow only for the
// curated map's hits. Since providers.ContextWindow now delegates to Meta()
// (which can loose-match across the generated table), we accept a value only
// if it came from the curated layer. A miss is reported as 0.
func legacyCuratedWindow(model string) int {
m, ok := providers.Meta(model)
if !ok || m.Source != "curated" {
return 0
}
return m.ContextWindow
}

// maybeCompact summarises older turns once the conversation approaches the
// model's context window, keeping recent turns verbatim. On success the
Expand Down
10 changes: 8 additions & 2 deletions internal/llm/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,9 +427,15 @@ func (c *anthropicClient) Models(ctx context.Context) ([]ModelInfo, error) {
}
out := make([]ModelInfo, 0, len(raw.Data))
for _, m := range raw.Data {
// The Anthropic /models endpoint does not return context window,
// max output, or capability flags. Emit only what the API tells us
// and let the server layer enrich from the models.dev snapshot;
// hard-coding 200000 across the family lied for Claude Sonnet 4.6
// (real window: 1M).
out = append(out, ModelInfo{
ID: m.ID, Name: firstNonEmpty(m.DisplayName, m.ID), Provider: c.opts.ProviderID,
ContextWindow: 200000, MaxOutput: 64000, Vision: true, Tools: true, Reasoning: true,
ID: m.ID,
Name: firstNonEmpty(m.DisplayName, m.ID),
Provider: c.opts.ProviderID,
})
}
return out, nil
Expand Down
14 changes: 11 additions & 3 deletions internal/llm/gemini.go
Original file line number Diff line number Diff line change
Expand Up @@ -627,10 +627,18 @@ func (c *geminiClient) Models(ctx context.Context) ([]ModelInfo, error) {
continue
}
id := strings.TrimPrefix(m.Name, "models/")
// Gemini /models returns real token limits (InputTokenLimit /
// OutputTokenLimit); use those. Capability flags — vision,
// reasoning — are not in the response; leave them zero so the
// server layer enriches from the models.dev snapshot instead of
// guessing from the id ("contains 2.5" was wrong for every
// non-Gemini id a proxy happens to expose).
out = append(out, ModelInfo{
ID: id, Name: firstNonEmpty(m.DisplayName, id), Provider: c.opts.ProviderID,
ContextWindow: m.InputTokenLimit, MaxOutput: m.OutputTokenLimit,
Vision: true, Tools: true, Reasoning: strings.Contains(id, "2.5") || strings.Contains(id, "3"),
ID: id,
Name: firstNonEmpty(m.DisplayName, id),
Provider: c.opts.ProviderID,
ContextWindow: m.InputTokenLimit,
MaxOutput: m.OutputTokenLimit,
})
}
return out, nil
Expand Down
25 changes: 17 additions & 8 deletions internal/providers/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,29 @@ type Info struct {
Models []string
}

// contextWindows records the true context window (in tokens) for models whose
// provider API does not report one, keyed by model id. The agent consults this
// when a config has no explicit model_meta, so the context gauge and compaction
// use the real window instead of the 200k default. Source: provider docs.
// contextWindows is the hand-curated escape hatch consulted by metadata.go's
// cascade. Prefer regenerating the models.dev snapshot (make sync-models)
// over adding an entry here; this map is for cases where upstream is missing
// or wrong and cannot wait for a refresh.
var contextWindows = map[string]int{
// Z.ai GLM — https://docs.z.ai/guides/llm
"glm-5.2": 1_000_000, // 1M context
// Kept as a reference example. The generated snapshot covers Z.ai too,
// so these values only win if models.dev regresses.
"glm-5.2": 1_000_000,
"glm-4.7": 200_000,
"glm-4.6": 200_000,
}

// ContextWindow returns the known context window for a model id, or 0 if none
// is catalogued.
func ContextWindow(model string) int { return contextWindows[model] }
// ContextWindow returns the known context window for a model id, walking the
// same cascade as Meta(): hand-curated overrides → generated models.dev
// snapshot → 0 when nothing knows. Callers layer their own user-config
// overrides on top; this function stays package-local by design.
func ContextWindow(model string) int {
if m, ok := Meta(model); ok {
return m.ContextWindow
}
return 0
}

var catalog = []Info{
{"anthropic", "Anthropic", "anthropic", "ANTHROPIC_API_KEY", "", true,
Expand Down
100 changes: 100 additions & 0 deletions internal/providers/generated_models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Package providers — see metadata.go for the type + cascade.
//
// This file wires the bundled models.dev snapshot (models_generated.json)
// into a lookup table used by Meta() and MetaByProvider(). The bundled
// snapshot is the offline-first fallback; refresh.go replaces it in place
// with a fresh models.dev pull once the process has network.
//
// Rationale for a JSON asset + //go:embed rather than a generated .go file
// full of struct literals: the snapshot is data, not code. Bundling it as a
// literal source file bloats every review with thousands of lines of key/
// value diffs whenever `make sync-models` runs. As a compact JSON asset the
// diff stays in one file GitHub already knows how to collapse, git history
// stays lean, and the runtime cost (one json.Unmarshal at process start) is
// trivial for ~1k entries.

package providers

import (
_ "embed"
"encoding/json"
"fmt"
"sync/atomic"
)

//go:embed models_generated.json
var generatedModelsRaw []byte

// generatedTable holds the current snapshot behind an atomic.Pointer so a
// background refresh can hot-swap it without racing readers. The Meta()
// callers read millions of times per session; the write path fires once at
// startup and again every refreshInterval, so lock-free reads are worth the
// tiny allocation each refresh.
var generatedTable atomic.Pointer[map[string]ModelMeta]

func init() {
initial := mustLoadGeneratedModels(generatedModelsRaw)
generatedTable.Store(&initial)
}

// generatedModels returns the current snapshot. Callers get a value copy of
// the map header — the underlying map is never mutated in place, only
// replaced wholesale, so the returned reference stays consistent for the
// duration of one lookup even if a refresh lands mid-call.
func generatedModels() map[string]ModelMeta {
p := generatedTable.Load()
if p == nil {
return nil
}
return *p
}

// setGeneratedModels swaps in a fresh snapshot. Called by the refresh
// goroutine in refresh.go once a live models.dev pull lands, and by tests
// that need a deterministic table.
func setGeneratedModels(m map[string]ModelMeta) {
generatedTable.Store(&m)
}

// generatedModelsEnvelope matches the shape written by scripts/sync-models-dev.go.
// Only the models map is loaded into memory — the _source/_fetched_at fields
// stay in the file for humans reading the diff and are not needed at runtime.
type generatedModelsEnvelope struct {
Models map[string]ModelMeta `json:"models"`
}

// mustLoadGeneratedModels parses the embedded snapshot at init time. A panic
// here would only fire on a malformed regeneration — a bug in the sync script
// or a hand-edit of the JSON — which is caught immediately by `go test`,
// never by an end user. Returning an empty map on decode failure would hide
// the bug behind silently missing metadata, which is worse.
func mustLoadGeneratedModels(raw []byte) map[string]ModelMeta {
if len(raw) == 0 {
return map[string]ModelMeta{}
}
var env generatedModelsEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
panic(fmt.Errorf("providers: parse embedded models_generated.json: %w", err))
}
if env.Models == nil {
return map[string]ModelMeta{}
}
return env.Models
}

// parseGeneratedModels is the non-panicking sibling used by the disk cache
// and live-fetch paths where a corrupted payload should be an error (log +
// keep prior snapshot), not a process kill.
func parseGeneratedModels(raw []byte) (map[string]ModelMeta, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("empty payload")
}
var env generatedModelsEnvelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, err
}
if env.Models == nil {
return map[string]ModelMeta{}, nil
}
return env.Models, nil
}
Loading
Loading