Files
krow_backend/go-api/internal/gateway/routing.go
Suriyakumarvijayanayagam 34fa58a6b9
Some checks failed
CI / test (push) Failing after 4m38s
CI / fixture (push) Failing after 7s
Remove the Anthropic path; the gateway speaks one wire protocol
The platform now runs on Groq by default, through the OpenAI-compatible
chat-completions shape. That shape is not one vendor — Gemini, OpenRouter,
Together, vLLM and a local Ollama serve it too — so moving again stays
configuration rather than code.

Two things in the deleted file were not Anthropic's and would have gone
with it silently:

  withRetry / MaxAttempts / retryBackoff were defined in anthropic.go and
  CALLED BY openai.go. Deleting the file wholesale would have removed the
  retry policy of the provider that survived, and nothing in openai.go
  mentions it, so the loss would have been invisible until the next 429.
  The policy is a property of this platform's runs, not of a vendor's API;
  it now lives in retry.go where no provider can carry it off.

  StreamComplete had the same problem and moves to gateway.go, beside the
  Streamer interface whose comment already referenced it.

Three stale-configuration failures are now refused at startup instead of
being ignored. Each was verified firing through the real config.Load():

  MODEL_PROVIDER=anthropic — named separately from every other wrong value
  because it used to be correct. Ignoring it gives a stack that believes it
  is on Claude while every run goes to Groq and is billed there.

  ANTHROPIC_API_KEY set while MODEL_API_KEY is empty. Ignoring a key an
  operator did set is the worst version of this: they fail every run on a
  missing credential they are looking straight at.

  A leftover claude-* model id, naming the tier that carries it. This is
  the check the previous commit's error-detail work was diagnosing: such an
  id is accepted by this process, rejected by the provider, and 400s on
  EVERY run. "A model is wrong" does not say which of three lines to edit.

Defaults ship as a matched pair. defaultBaseURL and the three tier ids are
one decision, not four: an id is only meaningful against the service that
serves it, and a Groq id on an OpenAI base URL is the same failure from the
other side. The tiers also stop being one model — a tier whose cost does
not differ is a distinction that buys nothing.

Verified end to end against a stub of the wire, driving the real wiring
(config.Load in production mode, gateway.New, StreamComplete): streamed
deltas, tool-call decoding, the loopback credential exemption, and usage
totalling 150 rather than 190 — the cached-prefix subtraction still holds.

gofmt clean, go vet clean, 14/14 non-DB packages pass. httpserver still
needs a reachable database.

NOT verified: the I7 planted-injection eval. Removing this path removed the
only model whose refusal behaviour had been measured against it, so the new
default is unproven there until `make eval-live` runs with a real key. The
Groq model ids should also be confirmed against Groq's current lineup.
Flagged in CLAUDE.md §12 and docs/handover.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
2026-09-05 11:52:21 +05:30

143 lines
5.6 KiB
Go

package gateway
import (
"github.com/krow/krow-backend/go-api/internal/config"
)
// ProviderOpenAI names the only wire protocol this platform speaks.
//
// One constant, not an enum, because there is one implementation. "openai" is
// the chat-completions shape — which is NOT only OpenAI. Groq, Gemini (through
// its compatible endpoint), OpenRouter, Together, vLLM and a local Ollama all
// serve it, and the difference between them is MODEL_BASE_URL and a model id,
// nothing more. Supporting six vendors is one implementation and six base URLs.
//
// The Anthropic path was removed deliberately, not lost. `MODEL_PROVIDER=anthropic`
// is now REFUSED at startup rather than ignored — see config.validateModel. A
// deployment carrying the old value must be told it moved, because the silent
// alternative is a stack that believes it is still on Claude while every run
// goes somewhere else.
const ProviderOpenAI = "openai"
// Effort is how hard a tier is allowed to think.
//
// PROVIDER-NEUTRAL ON PURPOSE, and the reason that mattered is now history
// worth keeping: this was a vendor SDK's own enum, baked into the routing table
// every provider has to read. Making it the platform's own vocabulary is what
// let that vendor be removed later without the routing table going with it —
// a one-line deletion instead of a re-typing of every tier.
//
// The three values are the platform's own vocabulary. Each implementation maps
// them onto whatever its API calls the same idea, and a provider with no such
// concept ignores them — the tier still selects the model, which is the larger
// lever anyway.
type Effort string
const (
EffortLow Effort = "low"
EffortHigh Effort = "high"
EffortXhigh Effort = "xhigh"
)
// Routing is how a tier becomes a model and an effort level.
//
// The model per tier is a deployment knob — a tenant on a different contract,
// or a deployment pinning a version through an incident, changes it without a
// spec edit. The *effort* per tier is not: "fast" and "deep" mean something
// specific about how much work an answer is worth, and letting a deployment
// redefine that would make the same spec behave differently in two places
// while claiming the same tier.
type Routing struct {
Model string
Effort Effort
}
// Config is the gateway's whole configuration surface.
//
// Built once at startup from the environment and passed in frozen, per §10.
// Nothing in this package reads the environment itself.
type Config struct {
// Provider selects the implementation. Empty means openai, which is now
// the only one; config.validateModel refuses any other value.
Provider string
APIKey string
// BaseURL points the OpenAI-compatible path at a specific service. Empty
// means OpenAI itself. This is the field that turns one implementation
// into a choice between Groq, Gemini, OpenRouter and a local Ollama.
BaseURL string
Fast Routing
Balanced Routing
Deep Routing
// MaxOutputTokens applies when a request does not set its own.
MaxOutputTokens int64
// SendReasoningEffort controls whether the OpenAI path transmits the
// effort level as `reasoning_effort`.
//
// OFF BY DEFAULT, and that default is the careful one. Reasoning models
// accept the field; most others reject the whole request with a 400 rather
// than ignoring an unknown key. A run that dies on a malformed request is
// worse than a run that thinks at the model's own default, so a deployment
// on a reasoning-capable model opts in rather than every other deployment
// opting out.
SendReasoningEffort bool
}
// FromConfig builds the gateway's routing table from validated settings.
//
// The effort per tier is fixed here rather than configured, and that is the
// point of the function existing at all: a deployment chooses *which model*
// answers a tier, and the platform chooses *how hard it thinks*. If a
// deployment could redefine effort, two installations running the same
// definition would disagree about what "deep" means while both reporting the
// tier as deep — and the tier is written into every trajectory.
//
// fast → low a lookup, a restatement, a short structured reading
// balanced → high the default, and what most turns should cost
// deep → xhigh a turn worth several tool calls and real deliberation
//
// `max` is deliberately not reachable from a spec. It is the setting for when
// correctness matters more than cost, which is a judgement an operator makes
// about a deployment, not one an agent author makes about a page.
func FromConfig(c config.ModelConfig) Config {
return Config{
Provider: c.Provider,
APIKey: c.APIKey,
BaseURL: c.BaseURL,
Fast: Routing{Model: c.Fast, Effort: EffortLow},
Balanced: Routing{Model: c.Balanced, Effort: EffortHigh},
Deep: Routing{Model: c.Deep, Effort: EffortXhigh},
MaxOutputTokens: int64(c.MaxOutputTokens),
SendReasoningEffort: c.ReasoningEffort,
}
}
// New builds the gateway a deployment's configuration asks for.
//
// One provider, so this is a constructor rather than a choice. It survives the
// removal of the second implementation because the runtime wires itself through
// `gateway.New(gateway.FromConfig(...))` and should not learn a concrete type:
// the next provider is a change here and nowhere else.
func New(cfg Config) Gateway {
return NewOpenAI(cfg)
}
// routingFor resolves a tier against a table.
//
// An unknown tier has already been normalised by ParseTier, so the default arm
// is reached only by a zero value.
func (c Config) routingFor(t Tier) Routing {
switch t {
case TierFast:
return c.Fast
case TierDeep:
return c.Deep
default:
return c.Balanced
}
}