diff --git a/.env.example b/.env.example index 064637085..695ea42eb 100644 --- a/.env.example +++ b/.env.example @@ -154,6 +154,22 @@ GROQ_API_KEY= # Groq free (chat fallback + reasoning) # env var was added for them on purpose: another os.environ indirection would # rebuild exactly the drift that #689 and #965 were. # +# SUPERSEDED AGAIN, 2026-08-23. On an owner directive both aliases moved back to +# OpenRouter, onto the FREE model dots-studio/dots-3-note-preview:free, at half +# their previous price, and route-groq-default and route-groq-auto are in turn +# retired in favour of route-free-default and route-free-auto. Derivation and +# live evidence: +# supabase/migrations/20260823_20_free_route_aliases_half_price.sql. +# +# The #689 lesson still holds and is the reason the new slug is ALSO a literal +# in config.yaml rather than a new env var. What changed is that the catalog +# price is no longer derived from the upstream's list cost, because the upstream +# is free; it is an owner-set number, so the failure mode #689 describes (a +# priced model named in the catalog while an undisclosed free model answers) is +# not reachable by pointing this route at a free model. The deploy assertion +# still guards the other half: that provider_routes.provider_model and the model +# LiteLLM actually calls agree. +# # * OPENROUTER_DEFAULT_MODEL is now UNUSED. Nothing reads it. Left in place # rather than deleted only because several CI workflows still pass it # through; treat it as dead and do not wire anything new to it. diff --git a/apps/control-plane/internal/routing/catalog_pricing_integration_test.go b/apps/control-plane/internal/routing/catalog_pricing_integration_test.go index 6989c4ee6..0c2d0e440 100644 --- a/apps/control-plane/internal/routing/catalog_pricing_integration_test.go +++ b/apps/control-plane/internal/routing/catalog_pricing_integration_test.go @@ -123,19 +123,32 @@ func TestSeededAliasHasExactlyOneEnabledRoute(t *testing.T) { } } -// TestHiveFastIsPinnedToGroqAtCorrectedPrice is requirement (c) at the data -// level. The expected credit figures are derived by hand from Groq's -// published rate for openai/gpt-oss-20b -// (20260818_01_revert_hive_fast_groq_model_decommissioned.sql). Groq -// decommissioned the previously-pinned llama-3.1-8b-instant sometime after -// 2026-08-03, so hive-fast was reverted to gpt-oss-20b, the model this route -// used before that cost migration, and its (unchanged) published rate. A -// later price or route change has to fail this test and be re-derived rather -// than drift silently: +// TestHiveFastIsPinnedToOneRouteAtItsUnchangedPrice is requirement (c) at the +// data level: hive-fast has exactly one enabled route, and its price is the +// figure the catalog derived for it. +// +// The price figures are unchanged from when this test was written, and that is +// now the load-bearing half. They were derived from Groq's published rate for +// openai/gpt-oss-20b +// (20260818_01_revert_hive_fast_groq_model_decommissioned.sql): // // input: 0.075 USD/M * 1.4 = 0.105 USD/M * 100_000 credits/USD = 10_500 // output: 0.300 USD/M * 1.4 = 0.420 USD/M * 100_000 credits/USD = 42_000 -func TestHiveFastIsPinnedToGroqAtCorrectedPrice(t *testing.T) { +// +// The ROUTE moved on 2026-08-23 +// (20260823_21_groq_text_routes_to_openrouter_free.sql): hive-fast, hive-small +// and hive-medium left Groq for an OpenRouter free model on an owner directive, +// to stop the Groq allowance being consumed. The price deliberately did NOT +// move with it. Only hive-default and hive-auto were repriced, by the companion +// migration 20260823_20, and this alias was not in that instruction's scope. +// +// So these two figures are no longer derivable from the upstream's cost, which +// is zero, and they must not be "corrected" to match it: a zero price makes an +// alias unselectable (RouteInfo.HasCostBasis) rather than free. They are held +// here as the DB-level guard that the repoint did not quietly reprice three +// customer-facing aliases. Any later price change has to fail this test and be +// re-derived rather than drift silently, exactly as before. +func TestHiveFastIsPinnedToOneRouteAtItsUnchangedPrice(t *testing.T) { pool := connectCatalogDB(t) var routeID, provider, providerModel, healthState string @@ -147,11 +160,14 @@ func TestHiveFastIsPinnedToGroqAtCorrectedPrice(t *testing.T) { if err != nil { t.Fatalf("hive-fast must have exactly one enabled route: %v", err) } - if provider != "groq" { - t.Errorf("hive-fast provider = %q, want groq", provider) + if provider != "openrouter" { + t.Errorf("hive-fast provider = %q, want openrouter", provider) } - if providerModel != "groq/openai/gpt-oss-20b" { - t.Errorf("hive-fast provider_model = %q, want groq/openai/gpt-oss-20b", providerModel) + // The `:free` suffix is the whole point of the repoint: without it the same + // slug resolves to a PAID endpoint, so the alias would charge an unchanged + // price against a real out-of-pocket cost. + if providerModel != "openrouter/dots-studio/dots-3-note-preview:free" { + t.Errorf("hive-fast provider_model = %q, want openrouter/dots-studio/dots-3-note-preview:free", providerModel) } var input, output int64 diff --git a/apps/control-plane/internal/routing/free_alias_pricing_test.go b/apps/control-plane/internal/routing/free_alias_pricing_test.go new file mode 100644 index 000000000..8a359c157 --- /dev/null +++ b/apps/control-plane/internal/routing/free_alias_pricing_test.go @@ -0,0 +1,659 @@ +package routing + +import ( + "math/big" + "regexp" + "strings" + "testing" +) + +// Offline guards over the 2026-08-23 free-route repricing migration. +// +// Why a separate file rather than widening catalog_alias_pricing_test.go: that +// file's guards all bottom out in the margin formula, credits = usd_per_million +// * 1.4 * 100000, and its parseRate refuses a zero rate outright ("that is a +// mispricing, not a rate"). A free upstream costs zero, so there is no rate to +// derive these two prices from and the formula cannot be the invariant here. +// +// What replaces it is the HALVING RELATION. The owner directed exactly 50 +// percent of each alias's current price, so the checkable property is +// new == old / 2 for every unit the alias bills, with the old figures pinned +// below from the migration that set them. That is also what makes these guards +// fail on the OLD rate: a migration that left hive-default at 10500 fails +// TestFreeAliasPricesAreExactlyHalfTheOldRates, because 10500 is not half of +// 10500. +// +// Everything here is positional, for the reason sqlparse_test.go documents: +// presence of a number somewhere in a file is not an assertion. 5250 and 21000 +// both appear in this migration, and 21000 is hive-default's NEW output price +// and hive-auto's OLD input price, so a guard that only looked for digits would +// pass with the two aliases' figures swapped. +const freePricingMigrationRelPath = "supabase/migrations/20260823_20_free_route_aliases_half_price.sql" + +// The second half of the same owner directive: the remaining Groq TEXT routes +// move to the same free model, and their prices do NOT change. Kept as its own +// file, and guarded separately, precisely so that "no price moves here" is a +// structural property of the file rather than a claim in its header. +const groqFreeMigrationRelPath = "supabase/migrations/20260823_21_groq_text_routes_to_openrouter_free.sql" + +// oldRates are the prices in force before this migration, read out of +// supabase/migrations/20260822_02_catalog_alias_restructure.sql step 7, which is +// the statement that set them. Pinned here rather than parsed from that file so +// that a later edit to it cannot silently move the baseline this halving is +// measured against. +var oldRates = map[string]map[string]int64{ + "hive-default": { + "input_price_credits": 10500, + "output_price_credits": 42000, + "cache_read_price_credits": 0, + "cache_write_price_credits": 0, + }, + "hive-auto": { + "input_price_credits": 21000, + "output_price_credits": 84000, + "cache_read_price_credits": 0, + "cache_write_price_credits": 0, + }, +} + +// moneyColumns is every column on model_aliases that carries a price. Both +// aliases are price_unit 'tokens', and precedence.go bills prompt tokens at +// input_price_credits and completion tokens at output_price_credits and reads +// neither cache column, but all four are published through +// catalog.CatalogPricing, so all four are covered. +var moneyColumns = []string{ + "input_price_credits", + "output_price_credits", + "cache_read_price_credits", + "cache_write_price_credits", +} + +// billedColumns are the two the gateway actually charges from. These are the +// ones that must never reach zero: routing.Service refuses an alias whose input +// and output prices are both zero, and a zero rate on a served request is the +// free-serve shape D-034 exists to prevent. +var billedColumns = []string{"input_price_credits", "output_price_credits"} + +// freeRouteByAlias is the route each alias must end up pinned to. +var freeRouteByAlias = map[string]string{ + "hive-default": "route-free-default", + "hive-auto": "route-free-auto", +} + +// retiredGroqRoutes are the routes this migration must disable, one per alias. +var retiredGroqRoutes = []string{"route-groq-default", "route-groq-auto"} + +// halveLineRe parses the migration's own declaration table: +// +// -- HALVE| alias | field | old | new +// +// Same convention as the DERIVE table in catalog_alias_pricing_test.go, and for +// the same reason: the migration states its arithmetic in a machine-readable +// form so a reader and a test cannot disagree about what it claims. +var halveLineRe = regexp.MustCompile(`(?m)^--\s*HALVE\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([0-9]+)\s*\|\s*([0-9]+)\s*$`) + +type halveRow struct { + Alias string + Field string + Old string + New string + Source string +} + +func readFreePricingMigration(t *testing.T) string { + t.Helper() + return readRepoFile(t, freePricingMigrationRelPath) +} + +// freeMigrationSQL is the migration with all commentary removed. Structural +// assertions run on this, never on the raw text, because the header discusses +// price figures, route ids and `health_state = 'disabled'` at length and a +// guard matching raw text is satisfied by prose. +func freeMigrationSQL(t *testing.T) string { + t.Helper() + return stripSQLComments(readFreePricingMigration(t)) +} + +// parseHalveRows reads the HALVE table out of the RAW migration text. Raw on +// purpose: the table lives in comments, so the stripped form has none of it. +func parseHalveRows(t *testing.T) []halveRow { + t.Helper() + + matches := halveLineRe.FindAllStringSubmatch(readFreePricingMigration(t), -1) + if len(matches) == 0 { + t.Fatalf("%s declares no '-- HALVE|' rows; a repriced column with no declared old value cannot be checked against anything", freePricingMigrationRelPath) + } + + rows := make([]halveRow, 0, len(matches)) + for _, m := range matches { + rows = append(rows, halveRow{Alias: m[1], Field: m[2], Old: m[3], New: m[4]}) + } + return rows +} + +// aliasPriceUpdates returns, per alias, the money columns this migration +// assigns and the literal it assigns to each. +func aliasPriceUpdates(t *testing.T) map[string]map[string]string { + t.Helper() + return updateAssignments(freeMigrationSQL(t), "public.model_aliases", "alias_id") +} + +// TestFreeAliasHalveTableIsArithmeticallyHalf checks the migration's own +// declaration table before anything is compared to the SQL. A HALVE row whose +// new value is not exactly half its old value is a wrong claim, and every other +// guard in this file measures the SQL against these rows. +func TestFreeAliasHalveTableIsArithmeticallyHalf(t *testing.T) { + for _, row := range parseHalveRows(t) { + old, ok := new(big.Int).SetString(row.Old, 10) + if !ok { + t.Errorf("HALVE row %s/%s: old value %q is not an integer", row.Alias, row.Field, row.Old) + continue + } + got, ok := new(big.Int).SetString(row.New, 10) + if !ok { + t.Errorf("HALVE row %s/%s: new value %q is not an integer", row.Alias, row.Field, row.New) + continue + } + + // Exact integer halving, and the remainder is checked rather than + // discarded. Every figure in this migration halves cleanly; a remainder + // would mean a rounding decision was made silently, which is exactly + // what D-031 keeps out of the money path. + want, rem := new(big.Int).QuoRem(old, big.NewInt(2), new(big.Int)) + if rem.Sign() != 0 { + t.Errorf("HALVE row %s/%s: old value %s does not halve to a whole credit; a rounding rule would have to be stated and is not", row.Alias, row.Field, row.Old) + continue + } + if want.Cmp(got) != 0 { + t.Errorf("HALVE row %s/%s: claims %s, but half of %s is %s", row.Alias, row.Field, row.New, row.Old, want) + } + } +} + +// TestFreeAliasHalveTableMatchesTheRatesInForce pins the HALVE table's OLD +// column to the prices that were actually in force. Without this the halving is +// self-referential: a migration could claim any old value it liked and halve it +// correctly while charging whatever it wanted. +func TestFreeAliasHalveTableMatchesTheRatesInForce(t *testing.T) { + seen := map[string]map[string]bool{} + for _, row := range parseHalveRows(t) { + want, ok := oldRates[row.Alias] + if !ok { + t.Errorf("HALVE row names alias %q, which this migration has no business repricing", row.Alias) + continue + } + prev, ok := want[row.Field] + if !ok { + t.Errorf("HALVE row %s/%s names a column that is not a money column on model_aliases", row.Alias, row.Field) + continue + } + if row.Old != big.NewInt(prev).String() { + t.Errorf("HALVE row %s/%s claims the old rate was %s; 20260822_02 step 7 set it to %d", row.Alias, row.Field, row.Old, prev) + } + if seen[row.Alias] == nil { + seen[row.Alias] = map[string]bool{} + } + seen[row.Alias][row.Field] = true + } + + // Every money column of both aliases must be declared. A column left out of + // the table is a column no guard here covers. + for alias := range oldRates { + for _, col := range moneyColumns { + if !seen[alias][col] { + t.Errorf("no HALVE row for %s/%s; every billed and published price column must declare its halving", alias, col) + } + } + } +} + +// TestFreeAliasPricesAreExactlyHalfTheOldRates is the guard the owner directive +// reduces to, and the one that fails on the old rate. It reads the value the +// migration actually ASSIGNS to each money column of each alias and compares it +// to half the rate in force, so a correct HALVE comment above a wrong UPDATE +// below is caught. +func TestFreeAliasPricesAreExactlyHalfTheOldRates(t *testing.T) { + updates := aliasPriceUpdates(t) + + for alias, prev := range oldRates { + assigns, ok := updates[alias] + if !ok { + t.Errorf("%s reprices no columns on alias %s; the directive halves both aliases", freePricingMigrationRelPath, alias) + continue + } + for _, col := range moneyColumns { + got, ok := assigns[col] + if !ok { + t.Errorf("alias %s: %s is not assigned; an unrepriced column keeps its old rate", alias, col) + continue + } + want := prev[col] / 2 + if got != big.NewInt(want).String() { + t.Errorf("alias %s: %s = %s, want %d (exactly half of %d)", alias, col, got, want, prev[col]) + } + } + } + + // Nothing else may be repriced by this migration. A halving that leaks onto + // a third alias is a price change nobody asked for and no guard covers. + for alias := range updates { + if _, ok := oldRates[alias]; !ok { + t.Errorf("%s updates alias %q, which is outside the directive's scope", freePricingMigrationRelPath, alias) + } + } +} + +// TestFreeAliasPricesNeverReachZero is the fail-closed half. Halving a price +// twice more, or a typo dropping a digit, must not produce a free alias: a zero +// on both billed columns makes routing refuse the alias (RouteInfo.HasCostBasis), +// and a zero on one of them serves that token class for nothing, which is the +// shape that served this gateway free for three days in July (D-034). +func TestFreeAliasPricesNeverReachZero(t *testing.T) { + updates := aliasPriceUpdates(t) + + for alias := range oldRates { + for _, col := range billedColumns { + got, ok := updates[alias][col] + if !ok { + continue // already reported by the guard above + } + value, ok := new(big.Int).SetString(got, 10) + if !ok { + t.Errorf("alias %s: %s = %q is not an integer", alias, col, got) + continue + } + if value.Sign() <= 0 { + t.Errorf("alias %s: %s = %s. A served request would be billed nothing for that token class", alias, col, got) + } + } + } +} + +// TestFreeAliasRepricingChangesOnlyTheRate holds invariant 4 of the directive: +// which token classes are billed, and in what unit, must not move. Only the +// rate does. A brief of this shape previously produced a 262x overcharge by +// widening what was billed instead of only the rate. +func TestFreeAliasRepricingChangesOnlyTheRate(t *testing.T) { + forbidden := []string{"pricing_mode", "price_unit"} + + for alias, assigns := range aliasPriceUpdates(t) { + for _, col := range forbidden { + if _, ok := assigns[col]; ok { + t.Errorf("alias %s: this migration assigns %s. A repricing may move the rate and nothing else; changing the mode or the unit changes WHAT is billed", alias, col) + } + } + } +} + +// TestFreeAliasRoutesTargetAFreeOpenRouterModel checks the routing half: each +// alias gets exactly one new OpenRouter route, and the slug is the free variant. +// +// The `:free` suffix is load-bearing rather than cosmetic. Dropping it selects a +// PAID endpoint of the same model, so the alias would be charging a halved price +// against a real out-of-pocket cost, and nothing else in the tree would notice. +func TestFreeAliasRoutesTargetAFreeOpenRouterModel(t *testing.T) { + sql := freeMigrationSQL(t) + + byRoute := map[string]map[string]string{} + for _, row := range insertRows(sql, "public.provider_routes") { + byRoute[row["route_id"]] = row + } + + for alias, routeID := range freeRouteByAlias { + row, ok := byRoute[routeID] + if !ok { + t.Errorf("%s inserts no route %s for alias %s", freePricingMigrationRelPath, routeID, alias) + continue + } + if row["alias_id"] != alias { + t.Errorf("route %s is attached to alias %q, want %q", routeID, row["alias_id"], alias) + } + if row["provider"] != "openrouter" { + t.Errorf("route %s provider = %q, want openrouter", routeID, row["provider"]) + } + model := row["provider_model"] + if !strings.HasPrefix(model, "openrouter/") { + t.Errorf("route %s provider_model = %q; LiteLLM strips a leading openrouter/ as its provider selector, so the prefix must be doubled", routeID, model) + } + if !strings.HasSuffix(model, ":free") { + t.Errorf("route %s provider_model = %q; the directive is a FREE model and the :free variant suffix is what selects the zero-priced endpoint", routeID, model) + } + // litellm_model_name is the model_name key in deploy/litellm/config.yaml. + // If it disagrees with route_id the config sync writes an entry nothing + // dispatches to. + if row["litellm_model_name"] != routeID { + t.Errorf("route %s litellm_model_name = %q, want the route id", routeID, row["litellm_model_name"]) + } + } +} + +// TestRetiredGroqRoutesAreDisabledAndRepointed makes sure the old route is taken +// out of service and no policy is left naming it. A policy pointing at a +// disabled route means SelectRoute finds no candidate and the alias 422s. +func TestRetiredGroqRoutesAreDisabledAndRepointed(t *testing.T) { + sql := freeMigrationSQL(t) + + for _, routeID := range retiredGroqRoutes { + disabled := false + for _, stmt := range splitStatements(sql) { + if disableRe.MatchString(strings.TrimSpace(stmt)) && strings.Contains(stmt, "'"+routeID+"'") { + disabled = true + break + } + } + if !disabled { + t.Errorf("%s does not disable %s; the alias would then have two enabled routes and an ambiguous price", freePricingMigrationRelPath, routeID) + } + } + + policies := updateAssignments(sql, "public.alias_route_policies", "alias_id") + for alias, routeID := range freeRouteByAlias { + assigns, ok := policies[alias] + if !ok { + t.Errorf("alias %s: fallback_order is not repointed, so its policy still names a route this migration disabled", alias) + continue + } + order := assigns["fallback_order"] + if !strings.Contains(order, routeID) { + t.Errorf("alias %s: fallback_order = %q, want it to name %s", alias, order, routeID) + } + for _, retired := range retiredGroqRoutes { + if strings.Contains(order, retired) { + t.Errorf("alias %s: fallback_order still names the disabled route %s", alias, retired) + } + } + } +} + +// TestFreeRoutesKeepTheCapabilitiesTheirAliasesServeToday guards the 422 that an +// under-claim produces. Both aliases are pinned to exactly ONE route, so a +// narrower replacement does not withhold a feature: matchesRequestedCapabilities +// drops the only candidate, SelectRoute returns ErrRouteNotEligible and +// writeRoutingError maps it to 422. tools_supported specifically is the column +// PR #206 routes tools, tool_choice and response_format on, and the chosen free +// model was verified live to support all three. +func TestFreeRoutesKeepTheCapabilitiesTheirAliasesServeToday(t *testing.T) { + required := []string{ + "supports_responses", + "supports_chat_completions", + "supports_completions", + "supports_streaming", + "supports_reasoning", + "tools_supported", + } + + caps := map[string]map[string]string{} + for _, row := range insertRows(freeMigrationSQL(t), "public.provider_capabilities") { + caps[row["route_id"]] = row + } + + for _, routeID := range freeRouteByAlias { + row, ok := caps[routeID] + if !ok { + t.Errorf("%s inserts no provider_capabilities row for %s; the column defaults are all false, so every endpoint would 422", freePricingMigrationRelPath, routeID) + continue + } + for _, flag := range required { + if !strings.EqualFold(row[flag], "true") { + t.Errorf("route %s: %s = %q, want true. On a pinned alias an under-claim is a failed request, not a withheld feature", routeID, flag, row[flag]) + } + } + // The inverse: claiming embeddings on a chat-only route would put this + // route in the embedding cascade's candidate set. + if strings.EqualFold(row["supports_embeddings"], "true") { + t.Errorf("route %s claims supports_embeddings; this is a chat model", routeID) + } + } +} + +// TestFreeRouteAutoCarriesTheSoleCapabilityFlagsForward is the same guard +// TestDisablingASoleCapabilityCarrierHandsItsFlagsOn applies to the 2026-08-22 +// migration, aimed at this one's target. route-groq-auto inherited +// supports_batch, supports_image_generation and supports_image_edit and is the +// only row in the catalog carrying them. SelectRoute hard-filters on each flag +// and batchstore sends NeedBatch = true for EVERY batch, so disabling that route +// without handing the flags on leaves /v1/batches, /v1/images/generations and +// /v1/images/edits with zero eligible routes for EVERY alias in the system. +func TestFreeRouteAutoCarriesTheSoleCapabilityFlagsForward(t *testing.T) { + sql := freeMigrationSQL(t) + + disabled := false + for _, stmt := range splitStatements(sql) { + if disableRe.MatchString(strings.TrimSpace(stmt)) && strings.Contains(stmt, "'route-groq-auto'") { + disabled = true + break + } + } + if !disabled { + t.Skip("route-groq-auto is not disabled by this migration, so its capabilities are not at risk") + } + + caps := insertRows(sql, "public.provider_capabilities") + if len(caps) == 0 { + t.Fatal("route-groq-auto is disabled but this migration inserts no provider_capabilities rows to hand its flags to") + } + + for _, flag := range soleCarrierFlags { + granted := false + for _, row := range caps { + if strings.EqualFold(row[flag], "true") { + granted = true + break + } + } + if !granted { + t.Errorf("this migration disables route-groq-auto, the only route in the catalog carrying %s, and grants that flag to no replacement route. Every endpoint gated on it would find zero eligible routes for every alias.", flag) + } + } +} + +// --------------------------------------------------------------------------- +// Second half of the directive: the remaining Groq TEXT routes move to the same +// free model, at UNCHANGED prices. +// --------------------------------------------------------------------------- + +// groqTextRepoints are the aliases the second migration moves, and the route +// each must end up on. Audio is deliberately absent: hive-stt and hive-tts stay +// on Groq, and TestGroqFreeRepointLeavesAudioOnGroq holds that. +var groqTextRepoints = map[string]string{ + "hive-small": "route-free-small", + "hive-medium": "route-free-medium", + "hive-fast": "route-free-fast", +} + +// retiredGroqTextRoutes are the routes that migration must disable. +var retiredGroqTextRoutes = []string{"route-groq-small", "route-groq-medium", "route-groq-fast"} + +// audioRoutes must survive untouched. OpenRouter offers no OpenAI-compatible +// speech endpoint at all and no model advertising selectable voices, and the +// only free models that take audio at all take it as chat input rather than +// through a transcription endpoint, so moving these would remove voice from the +// product rather than migrate it. +var audioRoutes = []string{"route-groq-stt", "route-groq-tts"} + +func groqFreeMigrationSQL(t *testing.T) string { + t.Helper() + return stripSQLComments(readRepoFile(t, groqFreeMigrationRelPath)) +} + +// TestGroqFreeRepointTouchesNoPrice is the guard that makes "prices unchanged" +// checkable rather than a promise in a comment. The owner's 50 percent +// instruction covers hive-default and hive-auto only; serving hive-small, +// hive-medium and hive-fast from a free upstream at their existing prices widens +// margin, which is the intended outcome. A price column written here would be +// an unrequested price change on three customer-facing aliases. +func TestGroqFreeRepointTouchesNoPrice(t *testing.T) { + sql := groqFreeMigrationSQL(t) + + // Two independent checks, because they fail differently. First: no UPDATE of + // model_aliases at all, which is the strongest form and the one the + // migration is written to satisfy. + if updates := updateAssignments(sql, "public.model_aliases", "alias_id"); len(updates) != 0 { + for alias, assigns := range updates { + t.Errorf("%s updates model_aliases for %s (%v); this migration must not write that table", groqFreeMigrationRelPath, alias, assigns) + } + } + + // Second: no price column name appears in any statement, which also catches + // an INSERT ... ON CONFLICT DO UPDATE or a shape updateAssignments does not + // model. + for _, col := range append(append([]string{}, moneyColumns...), "pricing_mode", "price_unit") { + if strings.Contains(strings.ToLower(sql), col) { + t.Errorf("%s mentions %s in an executable statement; the Groq repoint must not move a price", groqFreeMigrationRelPath, col) + } + } +} + +// TestGroqTextRoutesRepointToTheFreeModel is the routing half: each moved alias +// gets one new OpenRouter route on the free variant, and its policy follows. +func TestGroqTextRoutesRepointToTheFreeModel(t *testing.T) { + sql := groqFreeMigrationSQL(t) + + byRoute := map[string]map[string]string{} + for _, row := range insertRows(sql, "public.provider_routes") { + byRoute[row["route_id"]] = row + } + + for alias, routeID := range groqTextRepoints { + row, ok := byRoute[routeID] + if !ok { + t.Errorf("%s inserts no route %s for alias %s", groqFreeMigrationRelPath, routeID, alias) + continue + } + if row["alias_id"] != alias { + t.Errorf("route %s is attached to alias %q, want %q", routeID, row["alias_id"], alias) + } + if row["provider"] != "openrouter" { + t.Errorf("route %s provider = %q, want openrouter", routeID, row["provider"]) + } + model := row["provider_model"] + if !strings.HasPrefix(model, "openrouter/") { + t.Errorf("route %s provider_model = %q; the openrouter/ prefix must be doubled because LiteLLM strips the leading one", routeID, model) + } + if !strings.HasSuffix(model, ":free") { + t.Errorf("route %s provider_model = %q; without the :free variant suffix this selects a PAID endpoint, reintroducing the out-of-pocket spend this migration exists to remove", routeID, model) + } + if row["litellm_model_name"] != routeID { + t.Errorf("route %s litellm_model_name = %q, want the route id", routeID, row["litellm_model_name"]) + } + } + + policies := updateAssignments(sql, "public.alias_route_policies", "alias_id") + for alias, routeID := range groqTextRepoints { + assigns, ok := policies[alias] + if !ok { + t.Errorf("alias %s: fallback_order is not repointed, so its policy still names a route this migration disabled", alias) + continue + } + order := assigns["fallback_order"] + if !strings.Contains(order, routeID) { + t.Errorf("alias %s: fallback_order = %q, want it to name %s", alias, order, routeID) + } + for _, retired := range retiredGroqTextRoutes { + if strings.Contains(order, retired) { + t.Errorf("alias %s: fallback_order still names the disabled route %s", alias, retired) + } + } + // policy_mode must not move. hive-fast is 'latency' from its original + // seed and hive-small and hive-medium are 'pinned'; a repoint has no + // business changing selection strategy. + if _, ok := assigns["policy_mode"]; ok { + t.Errorf("alias %s: this migration assigns policy_mode; a route repoint must not change the selection strategy", alias) + } + } +} + +// TestGroqTextRoutesAreDisabled holds the other side: the old route is out of +// service, so no alias is left with two enabled routes and an ambiguous price. +func TestGroqTextRoutesAreDisabled(t *testing.T) { + sql := groqFreeMigrationSQL(t) + + for _, routeID := range retiredGroqTextRoutes { + disabled := false + for _, stmt := range splitStatements(sql) { + if disableRe.MatchString(strings.TrimSpace(stmt)) && strings.Contains(stmt, "'"+routeID+"'") { + disabled = true + break + } + } + if !disabled { + t.Errorf("%s does not disable %s; its alias would then have two enabled routes", groqFreeMigrationRelPath, routeID) + } + } +} + +// TestGroqFreeRepointLeavesAudioOnGroq is the out-of-scope guard, and it is the +// one with a live product behind it. Groq STT and TTS serve Bengali voice +// dictation, wired to the gateway in PR #1079. OpenRouter has no +// OpenAI-compatible speech endpoint and no model advertising selectable voices, +// so there is nothing to move these to; disabling them would delete voice from +// the product. +func TestGroqFreeRepointLeavesAudioOnGroq(t *testing.T) { + sql := groqFreeMigrationSQL(t) + + for _, routeID := range audioRoutes { + if strings.Contains(sql, routeID) { + t.Errorf("%s names %s in an executable statement; Groq audio is explicitly out of scope and must not be repointed or disabled", groqFreeMigrationRelPath, routeID) + } + } +} + +// TestRepointedGroqTextRoutesKeepTheirCapabilities carries the parity check onto +// the three moved routes. Two of them keep supports_reasoning true; hive-fast's +// stays false, which is status-quo preservation of a pre-existing under-claim on +// a deprecated alias that 20260822_02 examined and deliberately left alone. +// +// The free model's own parameter list omits reasoning_effort, stop, seed and the +// penalties, so parity was probed live rather than inferred: twelve request +// shapes including all of those, plus json_schema structured output, all +// returned 200. There is no request shape that works today and fails after the +// repoint. +func TestRepointedGroqTextRoutesKeepTheirCapabilities(t *testing.T) { + // route_id to the flags it must declare, mirroring the rows being replaced. + want := map[string]map[string]bool{ + "route-free-small": { + "supports_responses": true, "supports_chat_completions": true, + "supports_completions": true, "supports_streaming": true, + "supports_reasoning": true, "tools_supported": true, + "supports_embeddings": false, + }, + "route-free-medium": { + "supports_responses": true, "supports_chat_completions": true, + "supports_completions": true, "supports_streaming": true, + "supports_reasoning": true, "tools_supported": true, + "supports_embeddings": false, + }, + "route-free-fast": { + "supports_responses": true, "supports_chat_completions": true, + "supports_completions": true, "supports_streaming": true, + "supports_reasoning": false, "tools_supported": true, + "supports_embeddings": false, + }, + } + + caps := map[string]map[string]string{} + for _, row := range insertRows(groqFreeMigrationSQL(t), "public.provider_capabilities") { + caps[row["route_id"]] = row + } + + for routeID, flags := range want { + row, ok := caps[routeID] + if !ok { + t.Errorf("%s inserts no provider_capabilities row for %s; every column defaults to false, so every endpoint would 422", groqFreeMigrationRelPath, routeID) + continue + } + for flag, expected := range flags { + got := strings.EqualFold(row[flag], "true") + if got != expected { + t.Errorf("route %s: %s = %v, want %v", routeID, flag, got, expected) + } + } + // None of these three is a sole carrier of a media flag, so none of them + // may claim one. route-free-auto is where those live. + for _, flag := range soleCarrierFlags { + if strings.EqualFold(row[flag], "true") { + t.Errorf("route %s claims %s; the routes it replaces did not, and only route-free-auto carries the media flags", routeID, flag) + } + } + } +} diff --git a/deploy/litellm/config.yaml b/deploy/litellm/config.yaml index abb2e925c..90b1afd68 100644 --- a/deploy/litellm/config.yaml +++ b/deploy/litellm/config.yaml @@ -12,19 +12,37 @@ # Demo budget posture — paid-route count # ────────────────────────────────────── -# RECOUNTED 2026-08-22 by the catalog restructure. Every chat route except the -# two DeepSeek ones is now Groq, which is free to us at present; the aliases -# hive-default, hive-auto, hive-small, hive-medium and the deprecated hive-fast -# all resolve to one of two Groq gpt-oss models. The remaining OpenRouter spend -# is: route-deepseek-v4-flash, route-deepseek-v4-pro, route-doc-vlm (still -# OPENROUTER_AUTO_MODEL, the only vision-capable route left) and -# route-openrouter-embedding-fallback. So FOUR paid routes, down from a mix -# where the default and vision chat paths were both paid. +# RECOUNTED 2026-08-23. Owner directive, in two halves. hive-default and +# hive-auto left Groq for the OpenRouter FREE model +# dots-studio/dots-3-note-preview:free at HALF their previous price; hive-small, +# hive-medium and the deprecated hive-fast followed them onto the same free +# model at their prices UNCHANGED, to stop the Groq allowance being consumed. +# Migrations 20260823_20 and 20260823_21. # -# The concentration risk is the flip side and is called out here so a reader -# meets it in the same paragraph as the saving: with the chat surface almost -# entirely on one provider, and with no fallbacks left (see litellm_settings -# below), a Groq outage takes chat down entirely rather than degrading it. +# So EVERY chat route in this file is now OpenRouter, and Groq serves audio +# only (sections 6 and 7). GROQ_API_KEY is still required. +# +# The paid count is UNCHANGED at FOUR, because nothing moved into or out of the +# paid set: route-deepseek-v4-flash, route-deepseek-v4-pro, route-doc-vlm +# (still OPENROUTER_AUTO_MODEL) and route-openrouter-embedding-fallback. +# route-openrouter-auto-beta is billed at actual upstream cost rather than a +# catalog price and is bounded by provider.max_price, so it is paid but +# self-limiting. +# +# CONCENTRATION RISK, in the same paragraph as the saving. Five aliases +# (hive-default, hive-auto, hive-small, hive-medium, hive-fast) now resolve to +# ONE model at ONE provider on ONE free endpoint, and there are still no +# gateway fallbacks (see litellm_settings below). The failure mode MOVES rather +# than disappearing: OpenRouter documents free variants at 20 requests per +# minute, which is tighter than the Groq daily allowance this was meant to +# escape. It also removes the workaround that existed an hour earlier, namely +# switching to hive-small on a different provider; there is no such alias now. +# What a customer sees at the cap is a roughly 60 second wait and then a 502 +# reading "context canceled" rather than the provider's 429 with its retry hint +# (issue #1089, not fixed here: the candidate fix lives in the litellm_settings +# block the config sync preserves verbatim, so a file edit is inert on a live +# box). This does NOT close issue #1088, which is CI consuming the live demo's +# allowance and has a different owner. # # The historical note below is kept for the reasoning it records. # @@ -54,33 +72,56 @@ # openai/ generic adapter), so there is no verified free embedding slug to swap # the fallback to; it stays paid by necessity and is documented here. model_list: - # ── 1. Default chat (Groq gpt-oss-20b, hive-default) ──────────────────────── - # Was route-openrouter-default on OPENROUTER_DEFAULT_MODEL. The catalog - # restructure of 2026-08-22 moved hive-default onto Groq on cost grounds - # (OpenRouter is paid out of pocket, Groq is currently free to us) and gave - # it a new route id rather than repointing the old one. The old id is - # retired as `disabled` in provider_routes, so the config sync drops its - # entry from the live gateway; a new id is what lets that happen, because - # the OpenRouter-only `extra_body.provider` block above would otherwise have - # survived the field-level merge and been sent to Groq forever. + # ── 1. Default chat (OpenRouter FREE model, hive-default) ─────────────────── + # Owner directive 2026-08-23: hive-default and hive-auto move to an OpenRouter + # free model and their prices halve. Migration + # supabase/migrations/20260823_20_free_route_aliases_half_price.sql carries + # the full derivation, the live capability and data-policy evidence, and the + # before-and-after prices. + # + # Was route-groq-default (groq/openai/gpt-oss-20b), which is retired as + # `disabled` in provider_routes so the config sync drops its entry from the + # live gateway. A NEW route id is what lets that happen, and it is the same + # reason 20260822_02 gave when it retired route-openrouter-default: the sync + # merges field by field, the database owns only model, api_base and api_key, + # and anything else already on an entry survives forever. + # + # The doubled `openrouter/` prefix is correct: LiteLLM strips the leading one + # as its provider selector and forwards the rest, so this reaches OpenRouter + # as `dots-studio/dots-3-note-preview:free`. The trailing `:free` selects the + # zero-priced variant; dropping it silently selects a PAID endpoint of the + # same model. + # + # `provider.data_collection: deny` restricts routing to providers that do not + # collect user data. It is a fail-closed guard rather than decoration: this + # model resolves to one provider today (AtlasCloud, which does not train on + # prompts), and if that policy changes or a second provider appears the + # request fails instead of quietly moving customer prompts to a provider that + # stores them. Verified live 2026-08-23: with this preference set, five of + # five requests were served by non-training providers, and `zdr: true`, the + # stricter form, returns 404 because no zero-data-retention free endpoint + # exists at all right now. + # + # `allow_fallbacks: false` keeps OpenRouter from substituting a different + # provider for this model behind our back, which would defeat the guard + # above. # # The model is written literally, not through an env var. The upstream model # for a DB-managed route comes from provider_routes.provider_model via # POST /internal/litellm/sync (issue #713), and adding a fresh # os.environ/... indirection would just rebuild the two-mechanisms-one- - # checked drift that issues #689 and #965 were. Same shape as route-groq-stt - # and route-groq-tts below. This literal is only the first-boot seed. - - model_name: route-groq-default + # checked drift that issues #689 and #965 were. This literal is only the + # first-boot seed. + - model_name: route-free-default litellm_params: - model: groq/openai/gpt-oss-20b - api_key: os.environ/GROQ_API_KEY + model: openrouter/dots-studio/dots-3-note-preview:free + api_key: os.environ/OPENROUTER_API_KEY + extra_body: + provider: + data_collection: deny + allow_fallbacks: false # ── 2. Deprecated fast chat (hive-fast) ───────────────────────────────────── - # Serves Groq openai/gpt-oss-20b, via GROQ_FAST_MODEL, whose default is - # pinned to exactly that in .env.example (reverted there by issue #965 after - # Groq decommissioned llama-3.1-8b-instant). The older comment here named - # moonshotai/kimi-k2-instruct, which this route has not served for some time. - # # hive-fast is DEPRECATED as of the 2026-08-22 catalog restructure. It is # kept, priced and routed identically to hive-small, because the model id is # persisted per-conversation in existing Open WebUI chats and in live API @@ -90,54 +131,111 @@ model_list: # automatic cascade to the flagship route via litellm_settings.fallbacks; # those chat fallbacks are gone, deliberately, because a fallback answers # from a model the alias was not priced against. See litellm_settings below. - - model_name: route-groq-fast + # + # It used to serve Groq openai/gpt-oss-20b through GROQ_FAST_MODEL (a value + # issue #965 had to revert once already, after Groq decommissioned + # llama-3.1-8b-instant). + # + # MOVED OFF GROQ 2026-08-23 by the same owner directive, second half: the + # remaining Groq TEXT routes go to the OpenRouter free model to stop the Groq + # allowance being consumed. Migration + # supabase/migrations/20260823_21_groq_text_routes_to_openrouter_free.sql. + # Its price does NOT change; only hive-default and hive-auto were repriced. + # + # This also drops the `os.environ/GROQ_FAST_MODEL` indirection, the last one + # on a chat route. GROQ_FAST_MODEL is now unused; it stays in .env.example + # only because CI workflows still pass it through. + - model_name: route-free-fast litellm_params: - model: os.environ/GROQ_FAST_MODEL - api_key: os.environ/GROQ_API_KEY + model: openrouter/dots-studio/dots-3-note-preview:free + api_key: os.environ/OPENROUTER_API_KEY + extra_body: + provider: + data_collection: deny + allow_fallbacks: false - # ── 3. Larger chat (Groq gpt-oss-120b, hive-auto) ─────────────────────────── - # Was route-openrouter-auto on OPENROUTER_AUTO_MODEL, retired for the same - # reason and in the same way as route-openrouter-default above. + # ── 3. Larger chat (OpenRouter FREE model, hive-auto) ─────────────────────── + # Was route-groq-auto (groq/openai/gpt-oss-120b), retired for the same reason + # and in the same way as route-groq-default above. Same owner directive, same + # migration. # - # NOTE this route is no longer vision-capable. gpt-oss-120b is text-only, - # where gpt-4.1-mini was multimodal, so state the consequence plainly rather - # than pointing somewhere reassuring: after this change there is NO - # customer-reachable vision path through the gateway. hive-auto was the last - # one. + # It serves the SAME upstream model as route-free-default, and hive-auto is + # priced at twice hive-default, so the price gap now buys a customer nothing. + # That wart is recorded in the migration header and flagged to the owner + # rather than papered over: the directive was 50 percent of each alias's own + # current price, and no second free model has full capability parity without + # training on prompts, so a distinct larger model was not available to pick. # - # route-doc-vlm (section 4) is not a migration path for that traffic. It has - # no provider_routes row, which is why the config sync leaves it alone, and + # This route IS the sole catalog carrier of supports_batch, + # supports_image_generation and supports_image_edit, inherited from + # route-groq-auto and before that route-openrouter-auto. The two image flags + # remain a documented status-quo preservation, not a claim that this model + # generates images; dropping them would delete /v1/images/generations and + # /v1/images/edits for every alias in the catalog. See the migration header. + # + # ON VISION, correcting what the previous revision of this comment said. The + # 2026-08-22 restructure recorded that moving hive-auto to gpt-oss-120b left + # NO customer-reachable vision path through the gateway. This free model is + # `text+image->text`, so an image-input path exists again on both aliases at + # the upstream level. That is deliberately NOT declared as a capability: + # provider_capabilities has no vision column, so there is nothing to set and + # no catalog claim is being made. Treat it as an unadvertised property of the + # upstream, not a supported feature, until something actually declares it. + # + # route-doc-vlm (section 4) is still not a customer path. It has no + # provider_routes row, which is why the config sync leaves it alone, and # SelectRoute only ever returns routes joined to an alias, so nothing a # customer sends in the OpenAI-compatible `model` field can select it. Its # only consumer is apps/agent-engine/internal/docvlm, which addresses this # LiteLLM model name directly and never goes through the catalog. Do not # point a customer at it. # - # hive-auto also no longer performs any automatic model selection; the name - # is kept for back-compat only. - - model_name: route-groq-auto + # hive-auto still performs no automatic model selection; the name is kept for + # back-compat only. + - model_name: route-free-auto litellm_params: - model: groq/openai/gpt-oss-120b - api_key: os.environ/GROQ_API_KEY + model: openrouter/dots-studio/dots-3-note-preview:free + api_key: os.environ/OPENROUTER_API_KEY + extra_body: + provider: + data_collection: deny + allow_fallbacks: false - # ── 3b. Catalog restructure routes (2026-08-22) ───────────────────────────── - # hive-small and hive-medium are the provider-blind names for the two Groq - # gpt-oss models; hive-default and hive-auto above resolve to the same two - # models through their own route rows, per the one-alias-one-enabled-route - # rule. The two DeepSeek routes are the only paid OpenRouter chat routes - # left after this change. + # ── 3b. Catalog restructure routes (2026-08-22, repointed 2026-08-23) ─────── + # hive-small and hive-medium are provider-blind capability tiers. They used to + # be the two Groq gpt-oss models; as of 2026-08-23 they are on the same free + # OpenRouter model as every other chat alias, at their UNCHANGED prices. + # + # So after that change there is NO Groq chat route left in this file. Groq + # still serves audio (sections 6 and 7) and GROQ_API_KEY is still required; + # what it no longer serves is chat. Say the consequence plainly rather than + # pointing somewhere reassuring: every customer-reachable chat alias except + # the two paid DeepSeek ones now depends on ONE model at ONE provider on ONE + # free endpoint capped at 20 requests per minute, with no gateway fallbacks. + # There is no longer a same-capability alias on a different provider for a + # rate-limited customer to switch to. + # + # The two DeepSeek routes remain the only paid OpenRouter chat routes. # # The tilde in ~deepseek/deepseek-v4-flash-latest is part of the real # OpenRouter model id. Removing it silently selects a different, differently # priced model, so do not "tidy" it away. - - model_name: route-groq-small + - model_name: route-free-small litellm_params: - model: groq/openai/gpt-oss-20b - api_key: os.environ/GROQ_API_KEY - - model_name: route-groq-medium + model: openrouter/dots-studio/dots-3-note-preview:free + api_key: os.environ/OPENROUTER_API_KEY + extra_body: + provider: + data_collection: deny + allow_fallbacks: false + - model_name: route-free-medium litellm_params: - model: groq/openai/gpt-oss-120b - api_key: os.environ/GROQ_API_KEY + model: openrouter/dots-studio/dots-3-note-preview:free + api_key: os.environ/OPENROUTER_API_KEY + extra_body: + provider: + data_collection: deny + allow_fallbacks: false - model_name: route-deepseek-v4-flash litellm_params: model: openrouter/~deepseek/deepseek-v4-flash-latest diff --git a/docs/proof/free-route-aliases-half-price-2026-08-23/README.md b/docs/proof/free-route-aliases-half-price-2026-08-23/README.md new file mode 100644 index 000000000..8107447a9 --- /dev/null +++ b/docs/proof/free-route-aliases-half-price-2026-08-23/README.md @@ -0,0 +1,560 @@ +# Every chat alias moves to a free OpenRouter upstream + +Captured 2026-08-23 on the branch `feat/free-route-aliases-half-price`. + +Two owner directives, same day, one branch because they touch the same catalog +rows and the same `deploy/litellm/config.yaml`. + +- **Part one, sections 1 to 5:** `hive-default` and `hive-auto` move to a free + OpenRouter model and their prices halve. +- **Part two, sections 6 to 10:** the remaining Groq TEXT routes (`hive-small`, + `hive-medium`, `hive-fast`) move to the same free model at prices + **unchanged**, to stop the Groq allowance being consumed. Groq speech to text + and text to speech are explicitly out of scope and untouched. + +Every credential is a placeholder in this log. The project's real +`OPENROUTER_API_KEY` was read from the shared `.env` at runtime and never +printed; the probe script logged only its length and a seven-character prefix, +neither of which is reproduced here. No URL in this capture carries a credential +in a query string. + +## 1. The money proof: same request shapes, before and after + +Produced by calling the production settlement function +`inference.CreditsForTokens` (`apps/edge-api/internal/inference/pricing.go`) +directly, once with the old catalog rates and once with the new ones, inside the +repo's own toolchain container. The arithmetic is not reimplemented here: that +function is the one the streaming and sync settlement paths both call, and it +delegates to `metering.ChargeCredits`, the single implementation of the +credits-per-million rule (D-031). + +Two figures per shape, because they answer different questions. + +- **exact (credit-millionths)** is quantity times rate, summed, before the single + division and the round half up. This is where "exactly half" is provable. +- **settled credits** is the whole-credit charge the ledger records. Both sides + round half up independently, so this can sit up to one credit above exactly + half without any rate being wrong. + +Old rates: hive-default 10500 in / 42000 out, hive-auto 21000 in / 84000 out. +New rates: hive-default 5250 / 21000, hive-auto 10500 / 42000. + +| alias | request shape | prompt tok | completion tok | old exact (credit-millionths) | new exact | exact ratio | old settled credits | new settled credits | +|---|---|---|---|---|---|---|---|---| +| hive-default | typical chat turn | 1200 | 400 | 29400000 | 14700000 | 0.500000 | 29 | 15 | +| hive-default | long context read | 32000 | 800 | 369600000 | 184800000 | 0.500000 | 370 | 185 | +| hive-default | prompt only, no completion | 1500 | 0 | 15750000 | 7875000 | 0.500000 | 16 | 8 | +| hive-default | completion only, no prompt | 0 | 900 | 37800000 | 18900000 | 0.500000 | 38 | 19 | +| hive-default | tool call only turn, tiny completion | 850 | 40 | 10605000 | 5302500 | 0.500000 | 11 | 5 | +| hive-default | fail closed byte estimate (72 prompt, 1000 completion) | 72 | 1000 | 42756000 | 21378000 | 0.500000 | 43 | 21 | +| hive-default | one token each, floor territory | 1 | 1 | 52500 | 26250 | 0.500000 | 1 | 1 | +| hive-default | 24 completion tokens, floor boundary | 0 | 24 | 1008000 | 504000 | 0.500000 | 1 | 1 | +| hive-default | negative counts clamped | -5 | -5 | 0 | 0 | n/a | 0 | 0 | +| hive-auto | typical chat turn | 1200 | 400 | 58800000 | 29400000 | 0.500000 | 59 | 29 | +| hive-auto | long context read | 32000 | 800 | 739200000 | 369600000 | 0.500000 | 739 | 370 | +| hive-auto | prompt only, no completion | 1500 | 0 | 31500000 | 15750000 | 0.500000 | 32 | 16 | +| hive-auto | completion only, no prompt | 0 | 900 | 75600000 | 37800000 | 0.500000 | 76 | 38 | +| hive-auto | tool call only turn, tiny completion | 850 | 40 | 21210000 | 10605000 | 0.500000 | 21 | 11 | +| hive-auto | fail closed byte estimate (72 prompt, 1000 completion) | 72 | 1000 | 85512000 | 42756000 | 0.500000 | 86 | 43 | +| hive-auto | one token each, floor territory | 1 | 1 | 105000 | 52500 | 0.500000 | 1 | 1 | +| hive-auto | 24 completion tokens, floor boundary | 0 | 24 | 2016000 | 1008000 | 0.500000 | 2 | 1 | +| hive-auto | negative counts clamped | -5 | -5 | 0 | 0 | n/a | 0 | 0 | + +Read the three columns that matter: + +1. **Exact ratio is 0.500000 on every row.** Not "roughly half" and not "a + fraction of a percent": exactly half, in integer rational arithmetic, for + every shape including prompt-only, completion-only, the tool-call-only turn + and the byte-estimated fail-closed shape. +2. **No row settles at zero where the old rate charged something.** The two rows + that settle 0 are the negative-count shape, where both sides settle 0 because + the counts are clamped to zero and nothing was billed before either. +3. **No row is billed more than half, beyond one credit of rounding.** The three + rows where `new * 2` exceeds `old` do so by exactly one credit + (15/29, 185/370 is exact, 370/739, 11/21, 1/1), which is the two independent + half-up roundings and the pre-existing one-credit floor, not a rate error. One + credit is 0.00001 USD at the repo's 100000-credits-per-USD constant. + +The one-credit floor (`CreditsForTokens`) is what keeps a sub-credit request off +zero, and it is why the two "floor territory" rows read 1 and 1 rather than 1 and +0. That floor predates this change. + +The assertions above were enforced, not merely eyeballed: the replay failed the +run if any exact ratio was not exactly one half, if any new charge was zero where +the old was positive, or if any new charge exceeded half by more than one credit. + +## 2. Mutation testing of the new guards + +`apps/control-plane/internal/routing/free_alias_pricing_test.go`. Six mutations +applied to the migration, each run through +`go test ./apps/control-plane/internal/routing/... -run 'Free|Retired'`: + +| mutation | result | +|---|---| +| M1 revert hive-default input to the old 10500 | FAIL TestFreeAliasPricesAreExactlyHalfTheOldRates | +| M2 hive-default output 21000 becomes hive-auto's 10500 | FAIL TestFreeAliasPricesAreExactlyHalfTheOldRates | +| M3 route-free-auto loses supports_batch and both image flags | FAIL TestFreeRouteAutoCarriesTheSoleCapabilityFlagsForward | +| M4 provider_model loses the `:free` variant suffix | FAIL TestFreeAliasRoutesTargetAFreeOpenRouterModel | +| M5 route-free-default loses tools_supported | FAIL TestFreeRoutesKeepTheCapabilitiesTheirAliasesServeToday | +| M6 the retired Groq routes are left enabled | FAIL TestRetiredGroqRoutesAreDisabledAndRepointed | +| control, migration restored | ok | + +Six of six mutations killed a guard. M1 is the specific one the brief asked for: +the suite goes red on the old rate and green on the new one. + +## 3. The free target: live evidence, not documentation reading + +`https://openrouter.ai/api/v1/models`, fetched 2026-08-23: 422 models, of which +22 price both prompt and completion at zero. A literal `openrouter/free` id does +exist; it is a router rather than a model. + +Capability requirement, derived from what the two aliases serve today: both +current routes declare `tools_supported = true`, which is the column PR #206 +routes `tools`, `tool_choice` and `response_format` on. Of the 22 free models, +five support all of tools, tool_choice, response_format and structured_outputs. +Joined to `https://openrouter.ai/api/frontend/v1/all-providers`: + +| free model | provider | trains on prompts | retention | +|---|---|---|---| +| dots-studio/dots-3-note-preview:free | AtlasCloud | no | retains, period not published | +| z-ai/glm-5.2:free | Decart | no | zero retention | +| nvidia/nemotron-3-super-120b-a12b:free | NVIDIA | YES | retains | +| nvidia/nemotron-nano-9b-v2:free | NVIDIA | YES | retains | +| liquid/lfm-2.5-2.6b:free | Liquid | YES | retains | + +A false-green worth recording, because the first version of this scan shipped it: +the endpoints API reports NVIDIA's provider as `Nvidia` while the provider +directory keys it under displayName `NVIDIA`. Joining on displayName alone misses +the record entirely and reports every NVIDIA free endpoint as no-training and +zero-retention, the exact opposite of the truth. Join on both fields. + +### Live probe results + +Requests sent straight to `https://openrouter.ai/api/v1/chat/completions` with +the project's key, five shots per configuration. + +**`z-ai/glm-5.2:free`, the only zero-retention candidate: unusable.** + +``` + sync status 429 + {"error":{"message":"Provider returned error","code":429,"metadata":{ + "raw":"z-ai/glm-5.2:free is temporarily rate-limited upstream. ...", + "provider_name":"Decart","is_byok":false, + "provider_error_code":"upstream_429", + "limit_source":"upstream_provider_shared_pool", ... + tools status 429 (same body) + response_format status 429 (same body) + stream status 429 (same body) +``` + +Four of four attempts refused. `limit_source` is an upstream provider shared +pool, so this is not our account's limit and buying credits cannot raise it. + +**`openrouter/free` with no provider preference: rejected on output quality.** + +``` + 200 nvidia/nemotron-3-ultra-550b-a55b:free via Nvidia 'HIVE-OK' + 200 nvidia/nemotron-3.5-content-safety:free via Nvidia 'User Safety: safe' + 200 nvidia/nemotron-3.5-content-safety:free via Nvidia '' + 200 nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free via Nvidia 'HIVE-OK' + 200 cohere/north-mini-code:free via Cohere 'HIVE-OK' + providers: {'Nvidia': 4, 'Cohere': 1} +``` + +Four of five landed on a provider that trains on prompts, and two of five landed +on a moderation classifier that answered a plain chat prompt with +`User Safety: safe` and then with an empty string. + +**`openrouter/free` with `provider: {data_collection: "deny"}`: works, and the +filter is real.** + +``` + 200 cohere/north-mini-code:free via Cohere 'HIVE-OK' + 200 dots-studio/dots-3-note-preview:free via AtlasCloud 'HIVE-OK' + 200 dots-studio/dots-3-note-preview:free via AtlasCloud 'HIVE-OK' + 200 cohere/north-mini-code:free via Cohere 'HIVE-OK' + 200 dots-studio/dots-3-note-preview:free via AtlasCloud 'HIVE-OK' + providers: {'Cohere': 2, 'AtlasCloud': 3} + + with tools: + 200 cohere/north-mini-code:free tool_call=True + 200 dots-studio/dots-3-note-preview:free tool_call=True + 200 cohere/north-mini-code:free tool_call=True + 200 cohere/north-mini-code:free tool_call=True + 200 cohere/north-mini-code:free tool_call=True +``` + +Every NVIDIA endpoint and the classifier are gone from the pool. Tool calls +produced on five of five. + +**`provider: {zdr: true}`, the stricter form: nothing to route to.** + +``` + 404 {"error":{"message":"No endpoints found matching your data policy + (Zero data retention). Configure: https://openrouter.ai/settings/privacy", + "code":404}} x3 +``` + +There is no zero-data-retention free endpoint reachable at all today. + +**`dots-studio/dots-3-note-preview:free`, the chosen target: full parity.** + +``` + sync status 200 + served_model = dots-studio/dots-3-note-preview:free + provider = AtlasCloud + usage = {"prompt_tokens": 19, "completion_tokens": 32, "total_tokens": 51, + "cost": 0, "cost_details": {"upstream_inference_cost": 0}, ...} + tools status 200 + served_model = dots-studio/dots-3-note-preview:free + tool_calls = [{"type":"function","function":{"name":"get_weather", + "arguments":"{\"city\": \"Dhaka\"}"}}] + finish_reason = tool_calls + response_format status 200 + text = '{"ok": true}' + stream status 200 chunks 20 + final served_model = dots-studio/dots-3-note-preview:free + usage = {"prompt_tokens": 16, "completion_tokens": 48, "total_tokens": 64, "cost": 0, ...} +``` + +`cost: 0` on every response is the upstream confirming the endpoint is free. + +One behaviour to know rather than discover: this model enables reasoning by +default, and a small `max_tokens` is consumed by reasoning tokens before any +visible content appears (`completion_tokens: 32` with `reasoning_tokens: 34` and +an empty string back). At a realistic completion budget it answers normally, as +the `HIVE-OK` rows above show. + +### Rate limits + +From `https://openrouter.ai/docs/api_reference/limits.md`, whose MDX constants +resolve to: free model variants are capped at **20 requests per minute**, and at +**50 requests per day** below 10 dollars of lifetime credit purchases or **1000 +per day** at or above it. `GET /api/v1/key` reports `is_free_tier: false` for +this account, and project memory records a 10 dollar purchase, so 1000 per day is +the expected tier. The key endpoint does not expose the purchased total, so that +is an expectation and not a measurement; its `rate_limit` field is documented as +deprecated and returns `requests: -1`. + +Separately, and more sharply, the Decart 429 above shows a per-provider shared +pool can refuse every request regardless of our account's standing. + +### What a customer sees at the cap, today + +A roughly 60 second wait and then a **502** whose message is `context canceled`. +That is issue #1089, unchanged by this work: `num_retries: 3` with +`request_timeout: 45` retries a rate-limited deployment three times, the SDK +suites time out at 60 seconds, and the provider's own 429 with its retry hint +never leaves the LiteLLM container log. This change makes that path materially +more likely, because 20 requests per minute is a much tighter ceiling than Groq's. + +Not fixed here, deliberately. The candidate fix +(`router_settings.retry_policy.RateLimitErrorRetries: 0`) lives in the +`litellm_settings` block that the config sync preserves verbatim, so a file edit +is inert on a live box and cannot be verified from a developer machine. #1089 +reaches the same conclusion about itself. + +### Logging and training terms + +- **OpenRouter itself** stores no prompts or completions unless the account opts + in. Both opt-ins (private input/output logging, and letting OpenRouter use + inputs/outputs for a 1 percent discount) are off by default + (`https://openrouter.ai/docs/guides/privacy/data-collection.md`). Request + metadata (token counts, latency) is always retained. +- **AtlasCloud**, the provider serving the chosen model: `training: false`, + `retainsPrompts: true`, with no retention period published. +- The route carries `provider.data_collection: deny`, which restricts routing to + providers that do not collect user data, as a fail-closed guard against the + policy changing or a second provider appearing. +- **The contradiction, stated because this product sells data sovereignty:** a + strict zero-retention posture is not achievable on any free OpenRouter endpoint + today, proved by the `zdr: true` 404 above. The best available free posture is + a provider that does not train but does retain for an unpublished period. The + owner directed the move to free models with this on the record. + +## 4. What is NOT proved here + +- **That the running gateway serves the new route.** The LiteLLM config is + seeded into a named volume only when absent, and the live change arrives + through `POST /internal/litellm/sync` reading `provider_routes`. There is no + SSH to the demo box from this environment and CI is the only remote hands, so + the on-box confirmation belongs to the deploy run. `deploy-demo-box.yml`'s + "Assert model catalog prices agree with the model LiteLLM will call" step is + the check that catches a stale volume: it compares + `provider_routes.provider_model` against what the live gateway resolves, and + its predicate (`pricing_mode = 'upstream_actual' OR input_price_credits > 0`) + covers both of these rows. +- **A ledger row at the new price.** That needs a served request against the + deployed stack after the migration applies. What is proved above is the + arithmetic, through the production function, for every request shape the alias + bills. +- **No screenshot.** This change alters no UI surface. The console catalog table + renders whatever price the API returns, and the two figures it will show are + the ones proved in section 1. + +## 5. Commands + +``` +# money replay, through the production settlement function +cd deploy/docker && docker compose --profile tools run --rm toolchain \ + "cd /workspace && go test ./apps/edge-api/internal/inference/... -count=1 -short -run TestZZHalvingReplay -v" +# (temporary harness, deleted after capture; not part of the branch) + +# new offline guards +cd deploy/docker && docker compose --profile tools run --rm toolchain \ + "cd /workspace && go test ./apps/control-plane/internal/routing/... -count=1 -short -run 'Free|Retired' -v" + +# full suites +cd deploy/docker && docker compose --profile tools run --rm toolchain \ + "cd /workspace && go test ./apps/control-plane/... -count=1 -short" +cd deploy/docker && docker compose --profile tools run --rm toolchain \ + "cd /workspace && go test ./apps/edge-api/... -count=1 -short" + +# config lints +npm run lint:litellm-config # PASS +npm run lint:litellm-routing # PASS + +# live probes (key read from .env at runtime, never printed) +curl -s https://openrouter.ai/api/v1/models +curl -s https://openrouter.ai/api/frontend/v1/all-providers +curl -s -H "Authorization: Bearer " \ + -d '{"model":"dots-studio/dots-3-note-preview:free","messages":[...]}' \ + https://openrouter.ai/api/v1/chat/completions +``` + +--- + +# Part two: the remaining Groq text routes, at unchanged prices + +Owner directive, same day, second half: move the Groq TEXT and chat-completion +models to OpenRouter free as well, to stop the Groq free-tier allowance being +consumed. Prices for those aliases stay exactly as they are. Groq speech to text +and text to speech are explicitly out of scope. + +## 6. Full migration chain applied to a real Postgres + +Not a file-parsing claim. A throwaway `pgvector/pgvector:pg17` container on its +own port, seeded with `.github/ci/test-db-bootstrap.sql` and then every file in +`supabase/migrations/` in order, exactly as `.github/workflows/ci.yml` does it. +Container removed afterwards. + +`all migrations applied`, no error. + +Enabled routes and prices, read back from that database: + +``` + alias_id | in_credits | out_credits | pricing_mode | price_unit | route_id | provider | provider_model +------------------------+------------+-------------+-----------------+------------+-------------------------------------+------------+--------------------------------------------------------- + deepseek-v4-flash | 8946 | 17892 | fixed | tokens | route-deepseek-v4-flash | openrouter | openrouter/~deepseek/deepseek-v4-flash-latest + deepseek-v4-pro | 157080 | 471240 | fixed | tokens | route-deepseek-v4-pro | openrouter | openrouter/deepseek/deepseek-v4-pro-0813 + hive-auto | 10500 | 42000 | fixed | tokens | route-free-auto | openrouter | openrouter/dots-studio/dots-3-note-preview:free + hive-default | 5250 | 21000 | fixed | tokens | route-free-default | openrouter | openrouter/dots-studio/dots-3-note-preview:free + hive-embedding-default | 1 | 0 | fixed | tokens | route-openrouter-embedding-fallback | openrouter | openrouter/qwen/qwen3-embedding-8b + hive-embedding-default | 1 | 0 | fixed | tokens | route-openrouter-embedding | openrouter | openrouter/nvidia/llama-nemotron-embed-vl-1b-v2:free + hive-embedding-default | 1 | 0 | fixed | tokens | route-nvidia-embedding | nvidia_nim | nvidia_nim/nvidia/llama-3.2-nemoretriever-300m-embed-v1 + hive-fast | 10500 | 42000 | fixed | tokens | route-free-fast | openrouter | openrouter/dots-studio/dots-3-note-preview:free + hive-medium | 21000 | 84000 | fixed | tokens | route-free-medium | openrouter | openrouter/dots-studio/dots-3-note-preview:free + hive-small | 10500 | 42000 | fixed | tokens | route-free-small | openrouter | openrouter/dots-studio/dots-3-note-preview:free + hive-stt | 0 | 4316667 | fixed | seconds | route-groq-stt | groq | groq/whisper-large-v3 + hive-tts | 0 | 3080000 | fixed | characters | route-groq-tts | groq | groq/canopylabs/orpheus-v1-english + openrouter-auto | | | upstream_actual | tokens | route-openrouter-auto-beta | openrouter | openrouter/openrouter/auto-beta +``` + +Read that against the directive: + +- `hive-default` 5250 / 21000 and `hive-auto` 10500 / 42000: halved, as part one. +- `hive-small` 10500 / 42000, `hive-fast` 10500 / 42000, `hive-medium` 21000 / 84000: **byte for byte what they were before this branch.** Repointed, not repriced. +- `hive-stt` and `hive-tts`: still `groq`, still healthy, still `seconds` and `characters` as their price units. Voice untouched. +- Every alias still `pricing_mode = fixed` and `price_unit = tokens` except the audio pair and `openrouter-auto`, none of which this branch touches. + +Enabled-route count per alias, where the query lists only violations of "exactly one": + +``` + alias_id | enabled +------------------------+--------- + hive-embedding-default | 3 +``` + +One row, and it is the pre-existing exception the integration suite already +records in `pendingMultiRouteAliases`. Every alias this branch touches has +exactly one enabled route. + +Capability carriers for the flags that gate whole endpoints: + +``` + route_id | supports_batch | supports_image_generation | supports_image_edit | supports_stt | supports_tts | health_state +-----------------------+----------------+---------------------------+---------------------+--------------+--------------+-------------- + route-free-auto | t | t | t | f | f | healthy + route-groq-auto | t | t | t | f | f | disabled + route-groq-stt | f | f | f | t | f | healthy + route-groq-tts | f | f | f | f | t | healthy + route-openrouter-auto | t | t | t | f | f | disabled +``` + +The three batch and image flags have a **healthy** carrier (`route-free-auto`), +so `/v1/batches`, `/v1/images/generations` and `/v1/images/edits` still find an +eligible route. `supports_stt` and `supports_tts` are still on the two healthy +Groq audio routes. + +Capability flags on the five free routes: + +``` + route_id | responses | chat | completions | streaming | reasoning | tools | embeddings +--------------------+-----------+------+-------------+-----------+-----------+-------+------------ + route-free-auto | t | t | t | t | t | t | f + route-free-default | t | t | t | t | t | t | f + route-free-fast | t | t | t | t | f | t | f + route-free-medium | t | t | t | t | t | t | f + route-free-small | t | t | t | t | t | t | f +``` + +`route-free-fast`'s `reasoning: f` is deliberate status-quo preservation: +`route-groq-fast` has carried that under-claim since its original 20260331_02 +seed, and 20260822_02 examined it and left it alone on the same reasoning. + +Alias policies, showing `policy_mode` unchanged on every row and only the route +name inside `fallback_order` moving: + +``` + hive-auto | weighted | ["route-free-auto"] + hive-default | stability | ["route-free-default"] + hive-fast | latency | ["route-free-fast"] + hive-medium | pinned | ["route-free-medium"] + hive-small | pinned | ["route-free-small"] + hive-stt | pinned | ["route-groq-stt"] + hive-tts | pinned | ["route-groq-tts"] +``` + +Idempotence, checked rather than claimed: both new migrations were re-applied to +the same database a second time. Both succeeded, and the prices afterwards were +identical. + +``` + alias_id | input_price_credits | output_price_credits | cache_read | cache_write +--------------+---------------------+----------------------+------------+------------- + hive-auto | 10500 | 42000 | 0 | 0 + hive-default | 5250 | 21000 | 0 | 0 + hive-fast | 10500 | 42000 | 1 | 4 + hive-medium | 21000 | 84000 | 0 | 0 + hive-small | 10500 | 42000 | 0 | 0 +``` + +`hive-fast`'s cache columns read 1 and 4 rather than 0 and 0. Those are the +stale OpenRouter-era values from the original 20260331_01 seed, which +20260822_02 examined and deliberately left alone so a deprecated alias would not +be repriced on any axis. This branch does not touch them either, for the same +reason. + +## 7. Integration suite against that same database + +``` +--- PASS: TestSeededAliasHasExactlyOneEnabledRoute +--- PASS: TestHiveFastIsPinnedToOneRouteAtItsUnchangedPrice +--- PASS: TestNoRouteIsSelectableButUnservable +--- PASS: TestSelectRouteRefusesUnpricedAlias +--- PASS: TestOneEnabledRoutePerAliasInSQL +--- PASS: TestHiveFastStaysInvocableAfterDeprecation +--- PASS: TestSelectRouteHiveFastResolvesToGroqAtGroqPrice +ok github.com/sakibsadmanshajib/hive/apps/control-plane/internal/routing +``` + +`internal/catalog` and `internal/litellmconfig` also pass with the integration +tag against this database. + +Two honest notes on that run. `TestHiveFastIsPinnedToGroqAtCorrectedPrice` was +renamed to `TestHiveFastIsPinnedToOneRouteAtItsUnchangedPrice` and its provider +expectation updated, because the route legitimately moved; its two price +assertions (10500 and 42000) are unchanged and are now the DB-level guard that +this repoint did not quietly reprice three aliases. And +`TestSelectRouteHiveFastResolvesToGroqAtGroqPrice` in `service_test.go` keeps a +name that no longer describes the catalog: it is a stub-driven test of the +`SelectRoute` algorithm with synthetic route fixtures, it reads neither the +database nor any migration, and it is left alone rather than renamed in an +unrelated file. + +Running the whole `./apps/control-plane/...` integration suite at once also +produced failures in `auditworker`, `marketplace` and `tenants`. Those are +package-parallelism collisions on one shared database, not this change: each +passes on its own against the same database, and none of them reads +`model_aliases`, `provider_routes`, `provider_capabilities` or +`alias_route_policies`. + +## 8. Capability parity for the moved Groq aliases, probed live + +The concern is real rather than theoretical: `dots-3-note-preview` lists +`tools`, `tool_choice`, `response_format`, `structured_outputs`, `reasoning`, +`include_reasoning`, `max_tokens`, `temperature` and `top_p`, and does **not** +list `reasoning_effort`, `stop`, `frequency_penalty`, `presence_penalty`, +`seed`, `top_k` or `logprobs`. The Groq gpt-oss models it replaces accept +several of those. So the question is whether an unlisted parameter fails or is +ignored. + +Twelve request shapes, live, 2026-08-23: + +``` +baseline 200 reasoning_tok=29 text='{"ok": true}' +reasoning_effort=low 200 reasoning_tok=100 text='{"ok": true}' +reasoning_effort=high 200 reasoning_tok=43 text='{"ok": true}' +stop 200 reasoning_tok=27 text='{"ok": true}' +frequency_penalty 200 reasoning_tok=29 text='{"ok": true}' +presence_penalty 200 reasoning_tok=28 text='{"ok": true}' +seed 200 reasoning_tok=27 text='{"ok": true}' +top_k 200 reasoning_tok=157 text='{"ok": true}' +logprobs + top_logprobs 200 reasoning_tok=52 text='{"ok": true}' +n=2 200 choices=1 text='{"ok": true}' +response_format json_schema strict 200 reasoning_tok=116 text='{"ok": true}' +reasoning_effort + require_parameters 200 reasoning_tok=72 text='{"ok": true}' +``` + +All twelve returned 200 with a correct answer. **There is no request shape that +works today and fails after the repoint**, which is the regression this section +exists to rule out. Note in particular that `provider.require_parameters: true` +together with `reasoning_effort` also succeeded, meaning OpenRouter considers +that parameter satisfied by this endpoint. + +Two behavioural differences that are not failures, recorded so nobody files them +as new bugs: + +- `reasoning_effort` is accepted and never rejected, but does not reliably + modulate effort on this model: `low` produced more reasoning tokens than + `high` in the same run. Requests keep working; the knob stops being + meaningful. +- `n=2` returns one choice. That is OpenRouter's existing behaviour for a + provider that does not implement `n`, identical on the routes being replaced. + +## 9. Audio: what OpenRouter actually offers, surveyed rather than assumed + +"No audio on OpenRouter" would have been an overstatement, so here is the whole +picture across all 422 models: + +- **Text to speech: nothing usable.** Not one model, free or paid, advertises + `supported_voices`. The only entries with audio in their output modality are + `google/lyria-3-pro-preview` and `google/lyria-3-clip-preview` (free, MUSIC + generation, no voice selection) and `openai/gpt-audio` and + `openai/gpt-audio-mini` (PAID, speech-to-speech chat). None is an + OpenAI-compatible `/v1/audio/speech` endpoint, which is what `internal/audio` + speaks. There is no replacement for Groq Orpheus at any price. +- **Speech to text: three free models take audio as chat input** + (`thinkingmachines/inkling:free`, `thinkingmachines/inkling-small:free`, + `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`). They are not a + transcription endpoint: they are chat-completions models, while + `route-groq-stt` is a LiteLLM `mode: audio_transcription` route that edge-api's + audio handler forwards multipart audio to. Using one would be a new + integration, not a repoint. All three are served by providers whose published + policy is training on prompts, which is a poor destination for dictated speech + in particular. + +Reported, not acted on. Groq keeps serving voice, and `GROQ_API_KEY` stays +required. + +## 10. What part two does NOT prove + +- Same as part one: that the running gateway serves the new routes. Five config + entries changed and the config is volume-seeded, so the on-box confirmation + belongs to the deploy run and its "Assert model catalog prices agree with the + model LiteLLM will call" step. +- That the free endpoint can carry the whole chat surface. It now carries every + chat alias except the two paid DeepSeek ones, at a documented 20 requests per + minute, and the workaround that existed an hour earlier (switch to hive-small + on Groq) no longer exists. That is a load question a migration cannot answer + and is the accepted risk recorded in the pull request. diff --git a/supabase/migrations/20260823_20_free_route_aliases_half_price.sql b/supabase/migrations/20260823_20_free_route_aliases_half_price.sql new file mode 100644 index 000000000..87ec697de --- /dev/null +++ b/supabase/migrations/20260823_20_free_route_aliases_half_price.sql @@ -0,0 +1,347 @@ +-- ============================================================================= +-- hive-default and hive-auto: route to an OpenRouter free model, halve the +-- price (owner directive, 2026-08-23). +-- +-- WHAT CHANGES +-- * hive-default REPOINTED from Groq openai/gpt-oss-20b to OpenRouter +-- dots-studio/dots-3-note-preview:free, and repriced to +-- EXACTLY HALF its current rate. +-- * hive-auto REPOINTED from Groq openai/gpt-oss-120b to the same free +-- model, and repriced to EXACTLY HALF its current rate. +-- +-- Nothing else in the catalog moves. hive-small, hive-medium, hive-fast, +-- deepseek-v4-flash, deepseek-v4-pro, openrouter-auto, hive-embedding-default, +-- hive-stt and hive-tts are untouched, so Groq remains reachable through +-- hive-small and hive-medium for a customer who needs a different upstream. +-- +-- THE PRICES, AND WHERE THE "CURRENT" FIGURES COME FROM +-- Read out of 20260822_02_catalog_alias_restructure.sql step 7, which is the +-- statement that set them, not from an estimate and not from memory. +-- +-- alias unit old new +-- hive-default credits per million prompt 10500 5250 +-- hive-default credits per million completion 42000 21000 +-- hive-default cache read (display only) 0 0 +-- hive-default cache write (display only) 0 0 +-- hive-auto credits per million prompt 21000 10500 +-- hive-auto credits per million completion 84000 42000 +-- hive-auto cache read (display only) 0 0 +-- hive-auto cache write (display only) 0 0 +-- +-- Every figure halves with no remainder, so there is no rounding decision +-- here and no place for a float to enter. The cache columns are already 0 on +-- both rows (20260822_02 zeroed them when both aliases moved to Groq routes +-- that declare no cache support), and half of zero is zero, so they are +-- asserted rather than changed. +-- +-- Both aliases stay pricing_mode 'fixed' and price_unit 'tokens'. Stated +-- because a plausible-sounding claim to the contrary was in circulation: +-- PR #1012 did NOT put hive-auto on upstream-actual pricing. It created a +-- SEPARATE alias, `openrouter-auto`, on route-openrouter-auto-beta, and its +-- own migration says so ("litellm_model_name is deliberately NOT +-- 'route-openrouter-auto': that name is the retired route id of the +-- pre-existing hive-auto alias, which resolves to a completely different +-- model"). hive-auto has been a plain fixed-price row throughout. +-- +-- WHY THE MARGIN FORMULA IS NOT USED HERE +-- Every previous pricing migration in this tree derives credits as +-- provider_list_usd_per_million * 1.4 * 100000 (D-032). A free upstream +-- costs zero, so that formula yields zero, and zero is not a price: routing +-- refuses an alias whose input and output prices are both zero +-- (routing.Service, RouteInfo.HasCostBasis) and it would be dead rather than +-- free. The existing guard test agrees, by construction: parseRate in +-- apps/control-plane/internal/routing/catalog_alias_pricing_test.go rejects a +-- zero rate as "a mispricing, not a rate". +-- +-- So these two figures are owner-set, not cost-derived, and the invariant +-- that replaces the formula is the HALVING RELATION against the pinned old +-- values above. That relation is machine-checked by +-- apps/control-plane/internal/routing/free_alias_pricing_test.go, which reads +-- the four money columns positionally out of this file and fails if any one +-- of them is not exactly half of the figure recorded beside it. +-- +-- WHY THIS MODEL, VERIFIED LIVE ON 2026-08-23 RATHER THAN ASSUMED +-- Requirement, from what the two aliases serve today: both current routes +-- declare tools_supported = true, which is the column PR #206 routes `tools`, +-- `tool_choice` and `response_format` on, plus supports_streaming and +-- supports_reasoning. A free target that cannot do those breaks requests +-- that work today. +-- +-- Of the 22 zero-priced models on https://openrouter.ai/api/v1/models +-- (422 models total, fetched 2026-08-23), only five support all four of +-- tools, tool_choice, response_format and structured_outputs. Joining them to +-- the provider data policies at +-- https://openrouter.ai/api/frontend/v1/all-providers: +-- +-- dots-studio/dots-3-note-preview:free AtlasCloud no training, retains +-- z-ai/glm-5.2:free Decart no training, zero retention +-- nvidia/nemotron-3-super-120b-a12b:free NVIDIA TRAINS ON PROMPTS +-- nvidia/nemotron-nano-9b-v2:free NVIDIA TRAINS ON PROMPTS +-- liquid/lfm-2.5-2.6b:free Liquid TRAINS ON PROMPTS +-- +-- A trap for whoever re-derives this: the endpoints API reports NVIDIA's +-- provider name as `Nvidia` while the provider directory keys it under +-- displayName `NVIDIA`. A join on displayName misses it and reports every +-- NVIDIA free endpoint as no-training and zero-retention, the exact opposite +-- of the truth. Join on both fields. +-- +-- Live probes with the project's real key, not documentation reading: +-- * z-ai/glm-5.2:free, the only zero-retention candidate, returned 429 on +-- 4 of 4 attempts, provider_error_code upstream_429, limit_source +-- upstream_provider_shared_pool. That is Decart's shared pool, not our +-- account's limit, so buying credits cannot raise it. +-- * openrouter/free (the router id does exist) succeeded 5 of 5, but 4 of +-- those landed on NVIDIA endpoints and 2 of 5 landed on +-- nvidia/nemotron-3.5-content-safety:free, a moderation classifier which +-- answered a plain chat prompt with "User Safety: safe" and then with an +-- empty string. Unfit for a chat alias on output quality alone. +-- * provider {zdr: true} returned 404 "No endpoints found matching your +-- data policy (Zero data retention)". There is no zero-data-retention +-- free endpoint reachable at all today, so a free upstream cannot be +-- squared with a strict zero-retention posture. That contradiction is +-- recorded here on purpose, because this product sells data sovereignty. +-- * dots-studio/dots-3-note-preview:free returned 200 on a sync +-- completion, on a tools request (a real tool_call with correct +-- arguments, finish_reason tool_calls), on response_format +-- {"type":"json_object"} (valid JSON back) and on a streamed request. +-- +-- Hence: the only free model that is simultaneously full-parity, +-- live-verified working, and served by a provider that does not train on +-- prompts. AtlasCloud publishes no retention period, which is the residual +-- and is not hidden. +-- +-- BOTH ALIASES GET THE SAME UPSTREAM MODEL, AND THAT IS A KNOWN WART +-- After this migration hive-auto costs exactly twice hive-default for the +-- identical model, so the surviving 2x gap buys a customer nothing. The +-- directive is 50 percent of each alias's own current price, which is what +-- this file implements; equalising them is a separate owner decision and +-- would be a further reduction, so it cannot overcharge anyone. The +-- alternative, giving hive-auto a distinct larger free model, is blocked: +-- no second free model has full capability parity without prompt training. +-- Two aliases sharing one upstream model through their own route rows is a +-- shape already established three times over by 20260822_02. +-- +-- WHY NEW ROUTE IDS RATHER THAN REPOINTING THE EXISTING ROWS +-- Same reason 20260822_02 gave, and it applies in this direction too. The +-- LiteLLM config sync merges FIELD BY FIELD: the database owns only model, +-- api_base and api_key, and every other key already on the entry survives so +-- that hand-tuning sticks (mergeParams in +-- apps/control-plane/internal/litellmconfig/generator.go, issue #707). +-- Retiring the route id makes the merge drop the whole stale entry, because a +-- known route_id that is no longer active is deleted from the config rather +-- than updated. Disabling rather than deleting the row keeps it reversible, +-- and SelectRoute filters 'disabled'. +-- +-- The api_key follows automatically: it comes from providers.api_key_env for +-- the row's provider slug, so provider 'openrouter' resolves to +-- OPENROUTER_API_KEY without this migration naming a secret. +-- +-- price_class STAYS 'standard' +-- 'budget' would arguably describe a free upstream better, but price_class +-- feeds alias_route_policies.allow_price_class_widening, and these two +-- aliases are 'pinned' with a one-entry fallback_order. Keeping the same +-- value as the routes being replaced means the repoint cannot change +-- selection behaviour through a second mechanism. One variable, not two. +-- +-- THE CAPABILITY LANDMINE, HANDLED +-- route-groq-auto is the SOLE carrier of supports_batch, +-- supports_image_generation and supports_image_edit in the whole catalog +-- (20260414_01 granted the media flags to route-openrouter-auto and to no +-- other row; 20260822_02 carried three of them onto route-groq-auto). +-- SelectRoute skips disabled candidates and then hard-filters on each flag, +-- and batchstore sends NeedBatch = true for EVERY batch, so disabling +-- route-groq-auto without carrying those three forward would make +-- /v1/batches, /v1/images/generations and /v1/images/edits find zero +-- eligible routes for EVERY alias in the system, not just for hive-auto. +-- route-free-auto therefore carries all three. +-- +-- Restating what 20260822_02 already said about the two image flags, so this +-- file cannot be read as a fresh claim: they are STATUS QUO PRESERVATION. +-- Neither gpt-4.1-mini, nor gpt-oss-120b, nor this free model is an +-- image-generation model. Carrying them keeps a routing change from silently +-- deleting two endpoints as a side effect. Correcting them properly needs a +-- real image route and is its own change. Do not read them as a claim that +-- this model generates images. supports_batch is a true claim: Phase 15's +-- control-plane local batch executor does not depend on the provider having a +-- native batch API. +-- +-- supports_reasoning is true on both rows and is verified rather than +-- inherited: the model's supported_parameters include `reasoning`, +-- `include_reasoning` and (on the router-visible entry) reasoning controls, +-- and edge-api sets NeedReasoning only when a request carries +-- reasoning_effort. An under-claim on a PINNED alias is not a withheld +-- feature, it is a 422, because with a single candidate +-- matchesRequestedCapabilities drops it and SelectRoute returns +-- ErrRouteNotEligible. +-- +-- supports_cache_read and supports_cache_write stay false. This model +-- publishes no cache-read rate, and no edge-api request path ever sets +-- NeedCacheRead or NeedCacheWrite, so a false flag here cannot become a 422. +-- +-- supports_embeddings stays false, matching both retired rows. +-- +-- WHAT THIS MIGRATION CANNOT DO +-- It cannot make the running gateway serve the new route. The LiteLLM config +-- is seeded into a named volume only when absent, and the sync preserves +-- litellm_settings verbatim, so the companion edit to +-- deploy/litellm/config.yaml is a first-boot seed and the live change arrives +-- through POST /internal/litellm/sync reading provider_routes. Verify against +-- the running gateway, not the file. +-- +-- It also does not fix issue #1089. A free endpoint is documented at 20 +-- requests per minute, far tighter than Groq, and today a rate limit reaches +-- the customer as a roughly 60 second wait and then a 502 whose message is +-- "context canceled", never as the 429 with its retry hint. The candidate fix +-- lives in the litellm_settings block that the sync preserves verbatim, so a +-- file edit is inert on a live box and unverifiable from a dev machine. It +-- stays with #1089. +-- +-- RE-RUNNABILITY +-- Every INSERT carries ON CONFLICT DO NOTHING and every UPDATE carries a +-- WHERE guard excluding rows already at the target value, so a second run +-- affects zero rows and errors on nothing. As with 20260822_02, replaying +-- this file after someone re-tunes hive-default's or hive-auto's price puts +-- those rows back to the 2026-08-23 values, which is what a repricing +-- migration is for but means it is NOT safe to replay over hand-tuned state. +-- ============================================================================= + +-- One transaction, for the reason 20260818_01 introduced it: a request landing +-- on ListRouteCandidates or LoadAliasPricing partway through must not see an +-- alias whose only enabled route has been disabled, or a route with no +-- capability row. +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +-- 1. One new route per alias. Two routes naming one upstream model is the same +-- shape 20260822_02 established for route-groq-small / route-groq-default +-- and route-groq-medium / route-groq-auto: provider_routes is keyed one +-- route to one alias, so each alias needs its own row. +insert into public.provider_routes ( + route_id, + alias_id, + provider, + provider_model, + litellm_model_name, + price_class, + health_state, + priority +) values + ( + 'route-free-default', + 'hive-default', + 'openrouter', + 'openrouter/dots-studio/dots-3-note-preview:free', + 'route-free-default', + 'standard', + 'healthy', + 10 + ), + ( + 'route-free-auto', + 'hive-auto', + 'openrouter', + 'openrouter/dots-studio/dots-3-note-preview:free', + 'route-free-auto', + 'standard', + 'healthy', + 10 + ) +on conflict (route_id) do nothing; + +-- The doubled `openrouter/` prefix above is correct and deliberate. LiteLLM +-- strips the leading `openrouter/` as its provider selector and forwards the +-- rest, so this reaches OpenRouter as the slug +-- `dots-studio/dots-3-note-preview:free`. Every other OpenRouter row in this +-- catalog carries the same doubling (20260822_02's +-- `openrouter/~deepseek/deepseek-v4-flash-latest`, 20260822_30's +-- `openrouter/openrouter/auto-beta`). Removing it would send +-- `dots-studio/dots-3-note-preview:free` to LiteLLM as an unknown provider. +-- The trailing `:free` is part of the real OpenRouter model id and selects the +-- zero-priced variant; dropping it selects a PAID endpoint of the same model, +-- which is a silent repricing of our own cost, not a tidy-up. + +-- 2. Capabilities per route. route-free-auto carries the three flags it is now +-- the sole catalog carrier of; see THE CAPABILITY LANDMINE above. +insert into public.provider_capabilities ( + route_id, + supports_responses, + supports_chat_completions, + supports_completions, + supports_embeddings, + supports_streaming, + supports_reasoning, + supports_cache_read, + supports_cache_write, + tools_supported, + supports_batch, + supports_image_generation, + supports_image_edit +) values + ('route-free-default', true, true, true, false, true, true, false, false, true, false, false, false), + ('route-free-auto', true, true, true, false, true, true, false, false, true, true, true, true) +on conflict (route_id) do nothing; + +-- 3. Retire the two Groq routes these aliases used to take. Disabled, not +-- deleted, so the change is reversible and the history survives. Groq +-- itself stays reachable: route-groq-small, route-groq-medium and +-- route-groq-fast are untouched and serve the same two upstream models. +UPDATE public.provider_routes + SET health_state = 'disabled' + WHERE route_id IN ('route-groq-default', 'route-groq-auto') + AND health_state <> 'disabled'; + +-- 4. Point each alias's policy at its new route, so no policy row names a +-- route that is no longer selectable. +UPDATE public.alias_route_policies + SET fallback_order = '["route-free-default"]'::jsonb + WHERE alias_id = 'hive-default' + AND fallback_order <> '["route-free-default"]'::jsonb; + +UPDATE public.alias_route_policies + SET fallback_order = '["route-free-auto"]'::jsonb + WHERE alias_id = 'hive-auto' + AND fallback_order <> '["route-free-auto"]'::jsonb; + +-- 5. Halve both money columns on both aliases. +-- +-- HALVE| alias | field | old | new +-- HALVE| hive-default | input_price_credits | 10500 | 5250 +-- HALVE| hive-default | output_price_credits | 42000 | 21000 +-- HALVE| hive-default | cache_read_price_credits | 0 | 0 +-- HALVE| hive-default | cache_write_price_credits | 0 | 0 +-- HALVE| hive-auto | input_price_credits | 21000 | 10500 +-- HALVE| hive-auto | output_price_credits | 84000 | 42000 +-- HALVE| hive-auto | cache_read_price_credits | 0 | 0 +-- HALVE| hive-auto | cache_write_price_credits | 0 | 0 +-- +-- Those HALVE rows are parsed by +-- apps/control-plane/internal/routing/free_alias_pricing_test.go, which +-- recomputes new = old / 2 in exact integer arithmetic and cross-checks each +-- figure against the column it actually lands in below. Editing a price +-- without editing the row beside it turns that test red, which is the whole +-- point: the margin formula that guards every other pricing migration +-- cannot apply to a zero-cost upstream. +UPDATE public.model_aliases + SET input_price_credits = 5250, + output_price_credits = 21000, + cache_read_price_credits = 0, + cache_write_price_credits = 0, + summary = 'Default alias for requests that name no model. Now resolves to a free upstream model, at half the previous price.', + updated_at = now() + WHERE alias_id = 'hive-default' + AND (input_price_credits <> 5250 OR output_price_credits <> 21000 + OR cache_read_price_credits <> 0 OR cache_write_price_credits <> 0); + +UPDATE public.model_aliases + SET input_price_credits = 10500, + output_price_credits = 42000, + cache_read_price_credits = 0, + cache_write_price_credits = 0, + summary = 'Larger-capacity alias. Performs no automatic routing or model selection; it resolves to the same free upstream model as the default alias, at half the previous price.', + updated_at = now() + WHERE alias_id = 'hive-auto' + AND (input_price_credits <> 10500 OR output_price_credits <> 42000 + OR cache_read_price_credits <> 0 OR cache_write_price_credits <> 0); + +COMMIT; diff --git a/supabase/migrations/20260823_21_groq_text_routes_to_openrouter_free.sql b/supabase/migrations/20260823_21_groq_text_routes_to_openrouter_free.sql new file mode 100644 index 000000000..df2fed9fc --- /dev/null +++ b/supabase/migrations/20260823_21_groq_text_routes_to_openrouter_free.sql @@ -0,0 +1,298 @@ +-- ============================================================================= +-- hive-small, hive-medium and hive-fast: move the remaining Groq TEXT routes to +-- the OpenRouter free model. PRICES DELIBERATELY UNCHANGED (owner directive, +-- 2026-08-23, second half of the same instruction as +-- 20260823_20_free_route_aliases_half_price.sql). +-- +-- WHAT CHANGES +-- * hive-small REPOINTED from Groq openai/gpt-oss-20b to OpenRouter +-- dots-studio/dots-3-note-preview:free. +-- * hive-medium REPOINTED from Groq openai/gpt-oss-120b to the same model. +-- * hive-fast REPOINTED from Groq (GROQ_FAST_MODEL, openai/gpt-oss-20b) to +-- the same model. Deprecated alias, kept invocable for +-- back-compat, moved so no Groq text route is left behind. +-- +-- WHY: to stop the Groq free-tier allowance being consumed. The Groq API key +-- stays configured and stays in use, for audio only (see below). +-- +-- PRICES DO NOT MOVE, AND THAT IS NOT AN OVERSIGHT +-- This migration writes to NO price column at all. Not to +-- input_price_credits, not to output_price_credits, not to either cache +-- column, on any alias. The owner's 50 percent instruction applies to +-- hive-default and hive-auto only, and 20260823_20 is where it is carried +-- out. Serving a same-priced alias from a free upstream simply widens margin, +-- and that is the intended outcome here. +-- +-- Stated this plainly because a reader who arrives from 20260823_20 will +-- reasonably expect a price change and its absence needs to read as +-- deliberate. It is also machine-checked: +-- TestGroqFreeRepointTouchesNoPrice in +-- apps/control-plane/internal/routing/free_alias_pricing_test.go fails if this +-- file assigns any price column. +-- +-- Consequence worth naming: after both migrations, five aliases resolve to one +-- upstream model at three different price points (hive-small and hive-fast at +-- 10500/42000, hive-medium at 21000/84000, hive-default at 5250/21000 and +-- hive-auto at 10500/42000). One model, five prices. That is the honest +-- description of what the two directives together produce, it is flagged to +-- the owner in the pull request rather than smoothed over here, and it is not +-- this file's decision to make. +-- +-- AUDIO IS EXPLICITLY OUT OF SCOPE +-- route-groq-stt (groq/whisper-large-v3) and route-groq-tts +-- (groq/canopylabs/orpheus-v1-english) are NOT touched and must not be. Groq +-- STT/TTS is what serves Bengali voice dictation, wired to the gateway in +-- PR #1079, so moving it would remove voice from the product rather than +-- migrate it. +-- +-- The survey behind that, done properly rather than asserted, because "no +-- audio on OpenRouter" would have been an overstatement. Across all 422 +-- models on /api/v1/models (2026-08-23): +-- * TEXT TO SPEECH: nothing. Not one model, free or paid, advertises +-- `supported_voices`, and the only entries with audio in their output +-- modality are google/lyria-3-pro-preview and google/lyria-3-clip-preview +-- (free, MUSIC generation, no voice selection) and openai/gpt-audio and +-- openai/gpt-audio-mini (PAID, speech-to-speech chat). None of them is an +-- OpenAI-compatible /v1/audio/speech endpoint, which is what +-- internal/audio speaks. There is no replacement for Orpheus here at any +-- price. +-- * SPEECH TO TEXT: three FREE models accept audio as chat INPUT +-- (thinkingmachines/inkling:free, thinkingmachines/inkling-small:free, +-- nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free). They are NOT a +-- transcription endpoint: they are chat-completions models, while +-- route-groq-stt is a LiteLLM `mode: audio_transcription` route that +-- edge-api's audio handler forwards multipart audio to. Using one would +-- be a new integration, not a repoint. All three are also served by +-- providers whose published policy is training on prompts, which is a +-- poor destination for dictated speech in particular. +-- Reported rather than acted on, exactly as the directive asked. +-- +-- Therefore GROQ_API_KEY remains required and the 'groq' provider row stays +-- enabled. What changes is that Groq no longer serves any CHAT traffic. +-- +-- THE FREE TARGET IS THE SAME ONE, FOR THE SAME REASONS +-- Full derivation, the live capability probes and the provider data-policy +-- evidence are in 20260823_20's header and are not duplicated here. The short +-- version: of the 22 zero-priced models on +-- https://openrouter.ai/api/v1/models (fetched 2026-08-23), only five support +-- all of tools, tool_choice, response_format and structured_outputs; of those +-- five, three are served by providers that train on prompts, and the one +-- remaining zero-retention candidate (z-ai/glm-5.2:free via Decart) returned +-- 429 on four of four live attempts from an upstream shared pool. That leaves +-- dots-studio/dots-3-note-preview:free as the only free model that is +-- simultaneously capability-complete, live-verified working, and served by a +-- provider that does not train on prompts. +-- +-- CONCENTRATION RISK, accepted by the owner and recorded rather than buried. +-- After this file, every customer-reachable chat alias in the catalog resolves +-- to ONE model at ONE provider on ONE endpoint, capped by OpenRouter at 20 +-- requests per minute for free variants. There are no gateway fallbacks, by +-- deliberate design (20260822_02). The failure mode therefore MOVES rather +-- than disappearing: a Groq daily allowance becomes a free per-minute +-- ceiling, which is tighter. It also removes the mitigation 20260823_20 could +-- still point at, namely "a rate-limited customer can select hive-small, +-- which is on a different provider". After this migration there is no such +-- alias. The only non-free chat routes left are the two paid DeepSeek ones, +-- and they are separate aliases a customer must choose deliberately. +-- +-- What a customer sees at the cap is unchanged and is not fixed here: a +-- roughly 60 second wait and then a 502 reading "context canceled", never the +-- provider's own 429 with its retry hint. That is issue #1089. The candidate +-- fix lives in the litellm_settings block that the config sync preserves +-- verbatim on a live volume, so a file edit is inert on the box and +-- unverifiable from a developer machine; #1089 says the same about itself. +-- This migration raises that issue's priority from latent to likely. +-- +-- This does NOT close issue #1088. That is CI consuming the live demo's +-- provider allowance, a different cause with a different owner. +-- +-- CAPABILITY PARITY, PROBED LIVE RATHER THAN READ OFF A TABLE +-- dots-3-note-preview lists tools, tool_choice, response_format, +-- structured_outputs, reasoning, include_reasoning, max_tokens, temperature +-- and top_p, and NOT reasoning_effort, stop, frequency_penalty, +-- presence_penalty, seed, top_k or logprobs. The Groq gpt-oss models it +-- replaces do accept several of those, so the question is whether an +-- unlisted parameter FAILS or is ignored. Twelve request shapes were sent +-- live on 2026-08-23: +-- +-- baseline, reasoning_effort=low, reasoning_effort=high, stop, +-- frequency_penalty, presence_penalty, seed, top_k, logprobs + +-- top_logprobs, n=2, response_format json_schema with strict: true, and +-- reasoning_effort together with provider.require_parameters +-- +-- ALL TWELVE returned 200 with a correct answer. Nothing 400s. So there is no +-- request shape that works today and fails after this change, which is the +-- regression this section exists to rule out. +-- +-- Two behavioural differences that are NOT failures and are recorded so +-- nobody reports them as new bugs: +-- * reasoning_effort is accepted and never rejected, but does not reliably +-- modulate effort on this model: 'low' produced more reasoning tokens +-- than 'high' in the same probe run. Requests keep working, the knob +-- stops being meaningful. +-- * n=2 returns one choice. That is OpenRouter's existing behaviour for a +-- provider that does not implement n, not something this change +-- introduces, and it is the same on the routes being replaced. +-- +-- Capability FLAGS are therefore carried across unchanged, per route: +-- route-free-small, route-free-medium identical to their Groq originals, +-- including supports_reasoning = true. +-- route-free-fast supports_reasoning stays FALSE. +-- +-- That last one is deliberate status-quo preservation, not an oversight. +-- route-groq-fast has carried supports_reasoning = false since its original +-- 20260331_02 seed; 20260822_02 examined it, called it a pre-existing +-- under-claim on a deprecated alias, and left it alone rather than widen it +-- in an unrelated migration. The same reasoning applies here. Widening it +-- would be safe (on a pinned single-route alias an under-claim is a 422 and a +-- widening cannot break a request), but a routing migration is not where a +-- deprecated alias should quietly gain a feature. +-- +-- NO ROUTE HERE CARRIES A SOLE-CARRIER FLAG +-- Unlike route-groq-auto, none of route-groq-small, route-groq-medium or +-- route-groq-fast carries supports_batch, supports_image_generation, +-- supports_image_edit, supports_tts or supports_stt. All three are +-- false-across-the-media-columns rows, so disabling them removes no endpoint +-- from the catalog. The batch and image flags live on route-free-auto, handed +-- there by 20260823_20; supports_stt and supports_tts live on route-groq-stt +-- and route-groq-tts, which this file does not touch. +-- +-- WHY NEW ROUTE IDS, AGAIN +-- Identical reasoning to 20260823_20 and 20260822_02: the LiteLLM config sync +-- merges field by field and the database owns only model, api_base and +-- api_key, so anything else already on a config entry survives forever. +-- Retiring the route id makes the merge drop the whole stale entry. +-- +-- It also removes an env-var indirection. route-groq-fast's config entry read +-- `os.environ/GROQ_FAST_MODEL`; route-free-fast writes the slug literally, the +-- same shape 20260822_02 chose for exactly the drift issues #689 and #965 +-- were. GROQ_FAST_MODEL becomes unused as a result, and is left in +-- .env.example rather than deleted because CI workflows still pass it +-- through. +-- +-- price_class STAYS 'standard' on all three, matching the rows they replace. +-- It feeds alias_route_policies.allow_price_class_widening, so holding it +-- constant keeps this repoint from changing selection behaviour by a second +-- mechanism. +-- +-- policy_mode IS NOT TOUCHED. hive-small and hive-medium are 'pinned'; +-- hive-fast is 'latency' from its 20260331_02 seed and stays 'latency' with a +-- single-entry fallback_order, exactly as 20260801_01 left it. Only the route +-- named inside fallback_order moves. +-- +-- RE-RUNNABILITY +-- Every INSERT carries ON CONFLICT DO NOTHING and every UPDATE carries a WHERE +-- guard excluding rows already at the target value, so a second run affects +-- zero rows and errors on nothing. +-- ============================================================================= + +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +-- 1. One new route per alias. +insert into public.provider_routes ( + route_id, + alias_id, + provider, + provider_model, + litellm_model_name, + price_class, + health_state, + priority +) values + ( + 'route-free-small', + 'hive-small', + 'openrouter', + 'openrouter/dots-studio/dots-3-note-preview:free', + 'route-free-small', + 'standard', + 'healthy', + 10 + ), + ( + 'route-free-medium', + 'hive-medium', + 'openrouter', + 'openrouter/dots-studio/dots-3-note-preview:free', + 'route-free-medium', + 'standard', + 'healthy', + 10 + ), + ( + 'route-free-fast', + 'hive-fast', + 'openrouter', + 'openrouter/dots-studio/dots-3-note-preview:free', + 'route-free-fast', + 'standard', + 'healthy', + 10 + ) +on conflict (route_id) do nothing; + +-- The doubled `openrouter/` prefix and the trailing `:free` are both +-- load-bearing; see 20260823_20's note. Dropping the prefix makes LiteLLM read +-- `dots-studio` as a provider it does not have, and dropping `:free` selects a +-- PAID endpoint of the same model, which would reintroduce out-of-pocket spend +-- on exactly the routes this migration exists to take off a metered allowance. + +-- 2. Capabilities, carried across per route. supports_reasoning is the only +-- column that differs between them, and it differs the same way it does +-- today: false on the deprecated fast route, true on the other two. +insert into public.provider_capabilities ( + route_id, + supports_responses, + supports_chat_completions, + supports_completions, + supports_embeddings, + supports_streaming, + supports_reasoning, + supports_cache_read, + supports_cache_write, + tools_supported, + supports_batch, + supports_image_generation, + supports_image_edit +) values + ('route-free-small', true, true, true, false, true, true, false, false, true, false, false, false), + ('route-free-medium', true, true, true, false, true, true, false, false, true, false, false, false), + ('route-free-fast', true, true, true, false, true, false, false, false, true, false, false, false) +on conflict (route_id) do nothing; + +-- 3. Retire the three Groq text routes. Disabled, not deleted, so the change is +-- reversible and SelectRoute filters them out. route-groq-stt and +-- route-groq-tts are deliberately absent from this list: audio stays on Groq. +UPDATE public.provider_routes + SET health_state = 'disabled' + WHERE route_id IN ('route-groq-small', 'route-groq-medium', 'route-groq-fast') + AND health_state <> 'disabled'; + +-- 4. Point each alias's policy at its new route, so no policy row names a route +-- this migration disabled. +UPDATE public.alias_route_policies + SET fallback_order = '["route-free-small"]'::jsonb + WHERE alias_id = 'hive-small' + AND fallback_order <> '["route-free-small"]'::jsonb; + +UPDATE public.alias_route_policies + SET fallback_order = '["route-free-medium"]'::jsonb + WHERE alias_id = 'hive-medium' + AND fallback_order <> '["route-free-medium"]'::jsonb; + +UPDATE public.alias_route_policies + SET fallback_order = '["route-free-fast"]'::jsonb + WHERE alias_id = 'hive-fast' + AND fallback_order <> '["route-free-fast"]'::jsonb; + +-- 5. There is deliberately no step 5. No summary text is rewritten and no price +-- column is written, on any alias. hive-small's and hive-medium's summaries +-- describe capability tiers rather than an upstream vendor, and the aliases +-- are provider-blind by design, so nothing customer-visible becomes untrue by +-- the repoint alone. Rewriting them would be the only way this file could +-- touch model_aliases, and TestGroqFreeRepointTouchesNoPrice is stricter and +-- simpler with that table left alone entirely. + +COMMIT;