pkg

package
v0.42.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 20, 2026 License: BSD-2-Clause Imports: 12 Imported by: 0

Documentation

Overview

Package cli holds the application struct that service.MainCmd parses CLI args into, plus the Run entry-point that delegates to the injected server factory. The factory itself lives in pkg/factory; this package is import-free of factory to keep the dependency direction (main -> factory -> ...) intact.

Package config loads and validates the claude-code-router YAML configuration. The config describes:

  • listed providers (each: upstream URL, optional token, list of model-name glob patterns)
  • which provider to route to when no glob matches (default_provider)

Routing is per-request: the model-router inspects the JSON body's `model` field and forwards to the matching provider's reverse proxy.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func FindConfigDir added in v0.19.0

func FindConfigDir(toolName string) string

FindConfigDir returns the config directory for toolName using XDG conventions with legacy dotfile fallback. Priority:

  1. ~/.config/<toolName>/ if it exists
  2. ~/.<toolName>/ if it exists
  3. ~/.config/<toolName>/ (XDG default when neither exists — new installs land in the XDG location from the start)

Deliberately does NOT use os.UserConfigDir() — on macOS that resolves to ~/Library/Application Support, which is not this project's XDG convention (~/.config/<tool>/ on every platform, matching task-watcher and vault-ui).

Types

type App

type App struct {
	Listen     string `arg:"listen"      default:"127.0.0.1:8788" env:"LISTEN"      required:"true"  usage:"address to listen to"`
	ConfigPath string `` /* 267-byte string literal not displayed */
	// contains filtered or unexported fields
}

App is the application wired by main and parsed by service.MainCmd's argument tagger. Exported fields with tags are CLI args; unexported fields are dependencies injected by main.

func NewApp

func NewApp(serverFactory ServerFactory) *App

NewApp constructs the App with the server factory injected.

func (*App) Run

func (a *App) Run(ctx context.Context) error

Run is invoked by service.MainCmd after argument parsing.

type AuthConfig added in v0.21.0

type AuthConfig struct {
	// Key is the legacy shared secret field. Kept solely so a legacy `auth:`
	// block still parses and is rejected at load; a non-nil AuthConfig makes
	// Config.Validate fail the config (fail-closed migration guard).
	Key string `yaml:"key" display:"length"`
}

AuthConfig parses the legacy spec-009 `auth:` block shape. It is a pointer on Config and exists only so yaml.Unmarshal can recognise a legacy block and trip the Config.Validate rejection — the parsed Key is never read for authentication.

type Config

type Config struct {
	Router    Router              `yaml:"router"`
	Providers map[string]Provider `yaml:"providers"`
	// DefaultToken is the optional top-level shared outbound bearer key
	// (spec 015). Every provider — and every Upstream pool member — that
	// declares no token: of its own resolves its outbound Authorization to
	// Bearer <DefaultToken>; a provider/member token: overrides it; with
	// neither set, the client's Authorization header passes through
	// unchanged. Absent or empty = no global default, today's behavior.
	// The key is operator config read only at wiring — never from client
	// input — and flows only in the outbound Authorization header, never
	// into logs or trace files (redacted like every other token).
	DefaultToken string `yaml:"default_token,omitempty"  display:"length"`
	// Aliases maps a short operator-typed model name to the full
	// model string the upstream expects. Resolved single-hop before
	// glob-routing: a request body `{"model":"qwen"}` becomes
	// `{"model":"qwen3.6:35b-a3b-coding-nvfp4"}` before the router
	// walks providers' models globs. Nil / empty map = no-op.
	Aliases map[string]string `yaml:"aliases,omitempty"`
	// ModelPools maps an invented model name to an ordered list of
	// members (spec 013). A client that sends `model: <poolname>` gets
	// the request body's model field rewritten to one member's concrete
	// model and routed through that member's provider — the router picks
	// the member per session, the client never sees it. Unlike Aliases
	// (one name -> one model), a pool name maps to a choice of models.
	// Nil / empty map = no-op.
	ModelPools map[string][]ModelPoolMember `yaml:"model_pools,omitempty"`
	// Trace, when true, enables per-request trace logging for /v1/*
	// requests: every request writes one JSON file capturing the full
	// request and response to ~/.claude-code-router/trace/. When false
	// (or absent), no trace files are written and no trace middleware
	// is allocated on the request hot path. Read once at Load; a
	// restart applies it.
	Trace bool `yaml:"trace,omitempty"`
	// Auth is the legacy spec-009 auth block. It is retained ONLY as a
	// load-failing detection probe: yaml.Unmarshal populates it from a
	// legacy `auth:` block, and Config.Validate rejects any non-nil value so
	// a config still carrying the removed auth path fails closed instead of
	// silently degrading to unauthenticated. Configure allowedApiKeys
	// instead. Absent and null both leave it nil and pass validation.
	Auth *AuthConfig `yaml:"auth,omitempty"`
	// AllowedApiKeys is the top-level registry of API keys that authenticate
	// non-loopback /v1/* requests. It is also the single rotation point: a
	// key that appears here (or in any provider's list) authenticates the
	// caller, and a per-provider claim pins routing. Absent, null, and empty
	// are equivalent and all mean: no key enforcement and no key routing —
	// the /v1/* path behaves exactly as it does today. Keys are literal
	// strings, like provider token: fields.
	AllowedApiKeys []string `yaml:"allowedApiKeys,omitempty" display:"length"`
	// ProviderOrder records the provider keys in YAML declaration order,
	// captured during unmarshal. Go maps cannot preserve iteration order, but
	// the router's "walk providers in declaration order, first glob match
	// wins" semantics depend on it once two providers share a model glob
	// (e.g. two seibert-vllm entries serving deepseek-* on separate quotas).
	// Populated only when the config was loaded from YAML; programmatically
	// built configs leave it empty and route-building falls back to sorted
	// order.
	ProviderOrder []string `yaml:"-"`
}

Config is the parsed YAML root.

func Load

func Load(ctx context.Context, rawPath string) (*Config, error)

Load reads, parses, and validates the config at path. Tilde-prefix (~/) is expanded to the user's home directory.

func (*Config) AllowedApiKeySet added in v0.27.0

func (c *Config) AllowedApiKeySet() map[string]struct{}

AllowedApiKeySet returns the set of keys that authenticate non-loopback /v1/* requests: the top-level registry when non-empty, else the union of every provider's allowedApiKeys. The empty set means auth is disabled and no key routing applies. This is the single definition the auth middleware (prompt 2) and the key router (prompt 3) consume — do not recompute the union elsewhere.

func (*Config) UnmarshalYAML added in v0.30.1

func (c *Config) UnmarshalYAML(value *yaml.Node) error

UnmarshalYAML decodes the config normally and additionally records the order in which providers are declared. Without this, CreateRouterFromConfig would build its route list in random Go map-iteration order and, with two providers sharing a model glob, the keyless path would be a per-restart coin flip instead of deterministic declaration order (spec 010).

func (*Config) Validate

func (c *Config) Validate(ctx context.Context) error

Validate checks that the parsed config is internally consistent.

type ModelPoolMember added in v0.37.0

type ModelPoolMember struct {
	Provider string `yaml:"provider"`
	Model    string `yaml:"model"`
	Weight   int    `yaml:"weight,omitempty"`
	Overflow bool   `yaml:"overflow,omitempty"`
}

ModelPoolMember is one candidate of a model pool: the provider to route through, the fixed concrete model string that provider sees, the weight for session-pinned member selection (default 1), and whether the member may overflow to a sibling when its provider is saturated (default false). Model is the concrete string sent to that provider — it may itself match the provider's models globs, which is the normal case (spec 013).

type Provider

type Provider struct {
	// Upstream is the base URL, e.g. https://api.anthropic.com.
	Upstream string `yaml:"upstream"`
	// Token, if set, replaces the client's Authorization header with
	// "Bearer <Token>". If empty, the client's Authorization is
	// forwarded verbatim — used for the subscription-OAuth case.
	Token string `yaml:"token,omitempty"                    display:"length"`
	// Models is the list of glob patterns (filepath.Match syntax) the
	// router uses to match request body's `model` field. Examples:
	// "claude-opus-*", "MiniMax-*", "qwen*".
	Models []string `yaml:"models"`
	// RequiresLeadingSystem lists glob patterns (same syntax as
	// Models) naming models behind this provider whose chat template
	// rejects a system-role message that is not the first entry of
	// the conversation. When the resolved model name matches one of
	// these patterns, the router lifts every out-of-place system
	// message into the top-level system block before forwarding.
	//
	// Scoped per model, never per provider: ollama's system-position
	// restriction lives in each model's chat template, so qwen3.6 and
	// qwen3.8 behave differently behind one provider (verified
	// 2026-08-15 with identical curl payloads against the same ollama
	// instance: qwen3.6 -> 200, qwen3.8 -> 500).
	//
	// Absent, nil, and empty are equivalent and all mean "never
	// transform anything for this provider".
	RequiresLeadingSystem []string `yaml:"requiresLeadingSystem,omitempty"`
	// AllowedApiKeys is this provider's routing pin: a request whose
	// presented x-api-key is in this list is dispatched to this provider
	// (its outbound token), overriding model-glob selection. A key may
	// appear in both the top-level registry and a provider's list — the
	// registry is the auth superset, the provider claim is the routing pin.
	// A key must NOT be claimed by more than one provider (validation
	// error, see Config.Validate). Absent, null, and empty all mean: this
	// provider claims no keys, so it is only reachable via glob routing.
	AllowedApiKeys []string `yaml:"allowedApiKeys,omitempty"           display:"length"`
	// MaxConcurrentRequests, when > 0, caps how many /v1/* requests this
	// provider forwards upstream at the same time. Requests beyond the cap
	// queue for up to MaxConcurrentWaitSeconds; a request still waiting
	// when the queue wait elapses is answered HTTP 429 with an
	// Anthropic-shaped rate_limit_error body so the client's own backoff
	// retries cleanly. Absent, 0, or negative means unlimited — no
	// queueing, no router-issued 429, byte-for-byte current behavior.
	MaxConcurrentRequests int `yaml:"maxConcurrentRequests,omitempty"`
	// MaxConcurrentWaitSeconds is how long a queued request waits for a
	// free slot before the router answers HTTP 429. Only consulted on a
	// capped provider (MaxConcurrentRequests > 0); absent, 0, or negative
	// resolves to the 30s default at wiring.
	MaxConcurrentWaitSeconds int `yaml:"maxConcurrentWaitSeconds,omitempty"`
	// Upstreams is the pool of servers this provider routes to. When
	// present it wins over the legacy single Upstream field; validation
	// rejects a provider that sets both. Absent, the legacy form is
	// synthesized into a one-entry pool by normalizeUpstreams, so after
	// Load every provider has a non-empty Upstreams (spec 012).
	Upstreams []Upstream `yaml:"upstreams,omitempty"`
	// Window is the legacy single-upstream form's eligibility window
	// (spec 014): when set, normalizeUpstreams copies it onto the
	// synthesized single member (a one-member pool is still a pool).
	// Providers that declare an upstreams: list carry windows per entry —
	// setting a provider-level window AND upstreams: is rejected.
	Window *Window `yaml:"window,omitempty"`
}

Provider describes one upstream LLM API.

func (Provider) UpstreamList added in v0.33.0

func (p Provider) UpstreamList() []Upstream

UpstreamList returns the pool of upstreams this provider routes to: the configured Upstreams when present, else the legacy single upstream synthesized as a one-entry pool with Weight 1, the provider-level caps, and the provider-level window. Config.Validate already normalizes Load-ed configs, so this is always the configured list there; the fallback keeps programmatically-built configs (tests and direct CreateRouterFromConfig callers that bypass Load) working with the legacy single-upstream form.

type Router

type Router struct {
	// DefaultProvider is the provider key used when no model glob matches.
	// Must reference a key in Providers; validated on Load.
	DefaultProvider string `yaml:"default_provider"`
}

Router holds router-wide settings.

type ServerFactory

type ServerFactory func(ctx context.Context, listen, configPath string) (librun.Func, error)

ServerFactory is the dep cli requires to start the HTTP listener. Satisfied by factory.CreateServer. Returns the run.Func + any startup error (config load, validation, etc.).

type Upstream added in v0.33.0

type Upstream struct {
	Upstream                 string `yaml:"upstream"`
	Token                    string `yaml:"token,omitempty"                    display:"length"`
	Weight                   int    `yaml:"weight,omitempty"`
	MaxConcurrentRequests    int    `yaml:"maxConcurrentRequests,omitempty"`
	MaxConcurrentWaitSeconds int    `yaml:"maxConcurrentWaitSeconds,omitempty"`
	// Window, when set, restricts when this member is eligible: a member
	// whose window does not contain "now" is excluded from session
	// pinning and least-loaded selection (spec 014). Absent = always
	// eligible, today's behavior.
	Window *Window `yaml:"window,omitempty"`
}

Upstream is one server in a provider's pool. When a provider declares an `upstreams:` list, every /v1/* request for that provider is dispatched to exactly one member: a request carrying an x-session-id header is pinned to the same member every time (weighted ring hash of the session id), a request without one goes to the least-loaded member, and each member independently enforces its own MaxConcurrentRequests cap. Weight defaults to 1 when absent or 0; a negative weight is rejected at validation. The legacy single `upstream:` form is sugar for a one-entry pool with Weight 1 whose caps are the provider-level values (spec 012).

type Window added in v0.39.0

type Window struct {
	From  libtime.TimeOfDay `yaml:"from"`
	Until libtime.TimeOfDay `yaml:"until"`
}

Window is an optional per-upstream time-of-day eligibility window (spec 014). A member is eligible for a dispatch only while "now" (the router's injected clock, evaluated in the value's attached IANA location) is inside [From, Until). From > Until wraps overnight (e.g. 22:00 -> 06:00 covers 02:00 and excludes 14:00). A nil Window on an Upstream means always eligible — today's behavior. Each value carries its IANA location inline in the "HH:MM <location>" form (e.g. "18:00 Europe/Berlin"); libtime.ParseTimeOfDay handles it — there is no separate timezone field and no default-location decision. Malformed times and unknown locations fail at yaml parse; a Window missing either boundary fails validation.

func (*Window) Contains added in v0.40.0

func (w *Window) Contains(now libtime.DateTime) bool

Contains reports whether now falls inside the window. Eligibility is half-open: [From, Until). From > Until wraps overnight (e.g. 22:00 -> 06:00 covers 02:00 and excludes 14:00). From == Until is an empty window — no time is eligible. now is evaluated in the window's attached location (From.Location, else Until.Location, else UTC), so the boundary is the IANA wall clock of the config value, never the router host's local time (spec DB 2, AC 5).

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL