Skip to content

Commit 61d734a

Browse files
author
Hanzo
committed
models: bare enso is the default, and one place decides it
CTO call, reversing the cost-led pin to enso-flash: a Hanzo session opens on the rung that picks best. DEFAULT_MODEL = "enso". The default was previously restated in three places that disagreed with each other and with the constant, so changing the constant alone changed nothing a user could see: - lib/providers.ts said enso-flash - app/playground/page.tsx hardcoded "enso" plus a whole hand-written model list, and that list had drifted onto upstream vendor names — "DeepSeek V4 Pro" (DeepSeek), "Llama 4 Maverick" (Meta), "Gemma 4" (Google) — shipped live in the /playground chunk, which is the one picker a signed-out human can open - app/chat/page.tsx hardcoded "enso" labelled "Enso (auto)" plus its own parallel SelectItem list Both pages now read useModels() + DEFAULT_MODEL. The hand-written lists are gone, which also closes the brand leak: buildModelsFrom only ever admits zen/enso/claude/gpt, so an upstream name cannot re-enter the picker. The fourth restatement was subtler and was why a fresh SIGNED-IN session never displayed the default at all. resolveSmartRouting(null, null) returned enabled:true, so /v1/routing-defaults {present:false} put every new session on the `auto` sentinel and the popover read "Auto · smart routing" — DEFAULT_MODEL was dead text on that path. With no org policy a new session now opens on DEFAULT_MODEL. `auto` is NOT a second spelling of Enso: it is ai's own cross-family cheapest-capable router (controllers/auto_route.go), a different mechanism at a different layer, so it survives as an explicit pick and as an org policy (defaultSessionRouting) — it just no longer wins the default slot. Its row stopped borrowing Enso's name in the hint. FALLBACK_MODELS now leads with enso and carries the three rungs the family actually serves. Measured at the family service itself — enso.enso.svc:8080 GET /v1/models, the catalog api.hanzo.ai proxies: enso min_tier trial $4/$20 per Mtok enso-flash (free) $2/$4 enso-ultra min_tier paid $5/$25 So the accepted cost delta is 2x in / 5x out, and the default clears its own tier gate: commerce maps the starter plan to `trial`, and familyTierAllowed fails open when commerce cannot name a tier. enso-pro is deliberately NOT listed. ai synthesizes a listing entry for it from a pin (controllers/zen_client.go ensoFam.pins) so it appears in an authenticated /v1/models, but the family catalog has no such SKU and a pick would pass through verbatim and fail at generation. A picker entry that cannot serve is worse than an absent one; list it when the family serves it. Tests updated — they encoded the old decision. 47 suites / 395 tests green, tsc --noEmit clean, next build clean. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
1 parent 31a692a commit 61d734a

7 files changed

Lines changed: 79 additions & 56 deletions

File tree

app/chat/page.tsx

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
3939
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/overlay";
4040
import { Textarea } from "@hanzo/ui";
4141
import { cn } from "@/lib/utils";
42+
import { DEFAULT_MODEL } from "@/lib/providers";
43+
import { useModels } from "@/lib/hooks/use-models";
4244
import { type BotAgent, TEAM_PRESETS, getBotGateway } from "@/lib/bot-gateway";
4345

4446
interface Message {
@@ -83,12 +85,12 @@ export default function ChatPage() {
8385
role: "assistant",
8486
content: "Here are key strategies for optimizing React performance:\n\n1. **Code Splitting & Lazy Loading**\n - Use React.lazy() and Suspense for route-based splitting\n - Implement dynamic imports for heavy components\n\n2. **Memoization Techniques**\n - Use React.memo() for expensive components\n - Apply useMemo() for costly computations\n - Utilize useCallback() for function references\n\n3. **Virtual List Rendering**\n - Implement react-window or react-virtualized for long lists\n - Only render visible items in viewport\n\n4. **State Management**\n - Keep state as local as possible\n - Use context API judiciously\n - Consider state management libraries for complex apps\n\n5. **Bundle Optimization**\n - Tree shaking and dead code elimination\n - Minimize bundle size with tools like webpack-bundle-analyzer\n\nWould you like me to elaborate on any of these techniques?",
8587
timestamp: new Date(Date.now() - 3500000),
86-
model: "enso"
88+
model: DEFAULT_MODEL
8789
}
8890
],
8991
createdAt: new Date(Date.now() - 86400000),
9092
updatedAt: new Date(Date.now() - 3500000),
91-
model: "enso"
93+
model: DEFAULT_MODEL
9294
},
9395
{
9496
id: "2",
@@ -104,7 +106,7 @@ export default function ChatPage() {
104106
messages: [],
105107
createdAt: new Date(Date.now() - 259200000),
106108
updatedAt: new Date(Date.now() - 259200000),
107-
model: "enso"
109+
model: DEFAULT_MODEL
108110
}
109111
]);
110112

@@ -114,7 +116,9 @@ export default function ChatPage() {
114116
const [sidebarCollapsed, setSidebarCollapsed] = useState(
115117
() => typeof window !== "undefined" && !window.matchMedia("(min-width:1024px)").matches
116118
);
117-
const [selectedModel, setSelectedModel] = useState("enso");
119+
// Default and list both come from the one catalog — no literals here.
120+
const { models } = useModels();
121+
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL);
118122
const messagesEndRef = useRef<HTMLDivElement>(null);
119123
const textareaRef = useRef<HTMLTextAreaElement>(null);
120124
const [isStreaming, setIsStreaming] = useState(false);
@@ -501,12 +505,11 @@ export default function ChatPage() {
501505
<SelectValue />
502506
</SelectTrigger>
503507
<SelectContent>
504-
<SelectItem value="enso">Enso (auto)</SelectItem>
505-
<SelectItem value="claude-opus-4.8">Claude Opus 4.8</SelectItem>
506-
<SelectItem value="claude-5-sonnet">Claude Sonnet 5</SelectItem>
507-
<SelectItem value="claude-haiku-4.5">Claude Haiku 4.5</SelectItem>
508-
<SelectItem value="gpt-5.2">GPT-5.2</SelectItem>
509-
<SelectItem value="gpt-5.4">GPT-5.4</SelectItem>
508+
{models.map((m) => (
509+
<SelectItem key={m.value} value={m.value}>
510+
{m.label}
511+
</SelectItem>
512+
))}
510513
</SelectContent>
511514
</Select>
512515

app/playground/page.tsx

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
4040
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@hanzo/ui";
4141
import { ScrollArea } from "@hanzo/ui";
4242
import { HanzoLogo } from "@/components/HanzoLogo";
43+
import { DEFAULT_MODEL } from "@/lib/providers";
44+
import { useModels } from "@/lib/hooks/use-models";
4345
import { cn } from "@/lib/utils";
4446
import Link from "next/link";
4547
import { toast } from "@hanzo/ui";
@@ -76,8 +78,11 @@ export default function PlaygroundPage() {
7678
const [isGenerating, setIsGenerating] = useState(false);
7779
const [selectedPreset, setSelectedPreset] = useState("default");
7880

81+
// Left pane opens on the product default — DEFAULT_MODEL, never a literal.
82+
// The right pane is the CONTRAST it is compared against, so it names a
83+
// deliberately different model rather than restating the default.
7984
const [leftConfig, setLeftConfig] = useState<ModelConfig>({
80-
model: "enso",
85+
model: DEFAULT_MODEL,
8186
temperature: 0.7,
8287
maxTokens: 2048,
8388
topP: 1,
@@ -99,19 +104,11 @@ export default function PlaygroundPage() {
99104
const [results, setResults] = useState<ComparisonResult[]>([]);
100105
const [activeResult, setActiveResult] = useState<ComparisonResult | null>(null);
101106

102-
// Current models served by api.hanzo.ai/v1 (values are the gateway model ids).
103-
const models = [
104-
{ value: "enso", label: "Enso (smart routing)", provider: "Hanzo" },
105-
{ value: "enso-flash", label: "Enso Flash", provider: "Hanzo" },
106-
{ value: "claude-opus-4.8", label: "Claude Opus 4.8", provider: "Anthropic" },
107-
{ value: "claude-5-sonnet", label: "Claude Sonnet 5", provider: "Anthropic" },
108-
{ value: "claude-haiku-4.5", label: "Claude Haiku 4.5", provider: "Anthropic" },
109-
{ value: "gpt-5.2", label: "GPT-5.2", provider: "OpenAI" },
110-
{ value: "gpt-5.4", label: "GPT-5.4", provider: "OpenAI" },
111-
{ value: "deepseek-v4-pro", label: "DeepSeek V4 Pro", provider: "DeepSeek" },
112-
{ value: "llama-4-maverick", label: "Llama 4 Maverick", provider: "Meta" },
113-
{ value: "gemma-4-31b", label: "Gemma 4", provider: "Google" }
114-
];
107+
// The live gateway ladder — the same list the builder's picker reads, shaped
108+
// by the one catalog rule set in @/lib/providers. Never a hand-written list:
109+
// the previous one had drifted onto upstream vendor names (a brand leak) and
110+
// onto ids the gateway no longer serves.
111+
const { models } = useModels();
115112

116113
const presets = [
117114
{ value: "default", label: "Default", description: "Balanced settings" },
@@ -207,7 +204,6 @@ export default function PlaygroundPage() {
207204
<SelectItem key={model.value} value={model.value}>
208205
<div className="flex items-center justify-between w-full">
209206
<span>{model.label}</span>
210-
<Badge variant="outline" className="ml-2 text-xs">{model.provider}</Badge>
211207
</div>
212208
</SelectItem>
213209
))}

components/editor/ask-ai/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@ export function AskAI({
122122
// a concrete id = routing off, unset = follow the org default. A NEW session
123123
// (unset) opens on the org's server-driven default (`/v1/routing-defaults`) —
124124
// Auto when the org defaults routing on, else the concrete default model.
125-
// Fail-soft: with no org policy known this stays on Auto, exactly as before.
125+
// With no org policy known that is DEFAULT_MODEL — a new session opens on
126+
// Enso, the rung that picks best, not on the separate `auto` router.
126127
const [storedModel, setModel] = useLocalStorage<string>("model");
127128
// A dead id persisted by an older build (e.g. a retired `gpt-*-codex`) is
128129
// treated as UNSET so we open on smart-routing/default instead of sending an

components/editor/ask-ai/settings.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -137,16 +137,16 @@ export function Settings({
137137
</p>
138138
)}
139139

140-
{/* Auto (Enso smart routing) is the default and a first-class VALUE of
141-
the persisted `model` — the builder's "Routed: …" banner and the
142-
smart-routing card read it. Enso auto-picks the best model AND the
143-
provider per request, so there is no separate provider choice. The
144-
dropdown below is an optional explicit override (family-grouped:
145-
Enso / Zen / Anthropic / OpenAI). */}
140+
{/* `auto` is a first-class VALUE of the persisted `model` — the
141+
builder's "Routed: …" banner and the smart-routing card read it. It
142+
is the gateway's OWN cross-family router (cheapest capable across
143+
Enso / Zen / Anthropic / OpenAI), which is NOT what Enso does, so
144+
it says so rather than borrowing Enso's name. It is no longer the
145+
fresh-session default: that is Enso, in the list below. */}
146146
<div className="rounded-xl border border-border bg-card/60 p-1">
147147
<ModelRow
148148
label="Auto · smart routing"
149-
hint="Enso picks the best model & provider per request"
149+
hint="Routes each request to the cheapest capable model"
150150
selected={isAuto}
151151
onClick={() => onModelChange(AUTO_MODEL)}
152152
/>

lib/providers.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,21 @@ export type ModelOption = {
2424
};
2525

2626
// The model the builder opens on when neither storage nor the gateway pick one.
27-
// enso-flash, not bare enso, for two measured reasons:
28-
// 1. Tier. The enso ladder is gated per SKU in ai (`controllers/family_tier.go`,
29-
// min_tier from discovery): enso-flash is FREE — everyone — while `enso` is
30-
// trial+ and enso-ultra is paid-only. A signed-out or free-plan visitor can
31-
// only be served the flash rung, so it is the only correct fresh-session
32-
// default; anything above it defaults the builder to a denial.
33-
// 2. Cost. api.hanzo.ai GET /v1/models prices enso-flash at $2/$4 per Mtok and
34-
// enso at $4/$20 — 5x on output, and output is what a builder generates.
35-
// Both are Enso, both stream, and both pass isBuildModel/resolveModelId, so the
36-
// picker lists this and a user who wants the bigger rung still picks it.
37-
export const DEFAULT_MODEL = "enso-flash";
27+
// Bare `enso` — the rung that picks best. This is the ONE place the default
28+
// lives; no page, component or env var restates it.
29+
//
30+
// It costs more than the flash rung and that is the accepted trade. Measured at
31+
// the family service itself (enso.enso.svc:8080 GET /v1/models, the catalog the
32+
// gateway proxies): enso is $4/$20 per Mtok against enso-flash's $2/$4 — 2x in,
33+
// 5x out. Output dominates a builder's spend, so this is a real 5x.
34+
//
35+
// Tier: the same catalog marks enso `min_tier: "trial"`. Commerce maps the
36+
// starter plan to `trial` (ai `controllers/family_tier.go` commerceTierToLadder),
37+
// and a new account lands on starter with its auto-credit, so a signed-in user
38+
// clears this gate. `familyTierAllowed` also fails OPEN when commerce cannot
39+
// name a tier. Signed-out visitors never generate — /v1/generate is the auth
40+
// seam — so no reachable session defaults to a denial.
41+
export const DEFAULT_MODEL = "enso";
3842

3943
// The Hanzo gateway (api.hanzo.ai) serves the Zen/Enso ladder + connected
4044
// providers AND — since DO GenAI funded the proprietary catalog — a CURATED set
@@ -99,16 +103,21 @@ export type SmartRoutingState = {
99103
// never touched (follow the org default). `defaults` is the server-driven org
100104
// policy, or null when unknown (older cloud-api / fetch failed).
101105
//
102-
// Fail-soft: with no org policy, behave exactly as before — the user's local
103-
// preference alone, defaulting to on (smart routing was the prior default).
106+
// With no org policy the fresh session opens on DEFAULT_MODEL, not on `auto`.
107+
// `auto` used to win this slot, which meant the default model was never the
108+
// thing a new user saw — the composer said "Auto" and DEFAULT_MODEL was dead
109+
// text. Two answers to "what runs my prompt?"; the model default is the one the
110+
// product states, so it wins. `auto` survives as an explicit pick and as an org
111+
// policy (`defaultSessionRouting`) — it is a different router (ai's own
112+
// cross-family one), not a second spelling of Enso.
104113
// When the org disables routing, the toggle is off and locked regardless of any
105114
// local preference. Otherwise the user's override wins, else the org default.
106115
export function resolveSmartRouting(
107116
localPref: boolean | null,
108117
defaults: RoutingDefaults | null
109118
): SmartRoutingState {
110119
if (!defaults) {
111-
return { enabled: localPref ?? true, toggleDisabled: false };
120+
return { enabled: localPref ?? false, toggleDisabled: false };
112121
}
113122
if (!defaults.autoRoutingActive) {
114123
return { enabled: false, toggleDisabled: true };
@@ -210,10 +219,17 @@ export function buildModelsFrom(
210219
// source of truth; the gateway is. Keep it to the current Zen 5 ladder. It MUST
211220
// carry DEFAULT_MODEL: the offline path returns DEFAULT_MODEL verbatim, so a
212221
// default missing from this list would name a model the picker cannot show.
222+
// The enso rungs here are the three the family service actually serves
223+
// (enso.enso.svc:8080 GET /v1/models → enso, enso-flash, enso-ultra). `enso-pro`
224+
// is deliberately ABSENT: ai synthesizes a listing entry for it from a pin
225+
// (`controllers/zen_client.go` ensoFam.pins) but the family has no such SKU, so
226+
// a pick would pass through verbatim and fail at generation. A picker entry that
227+
// cannot serve is worse than an absent one — list it only once the family does.
213228
export const FALLBACK_MODELS: ModelOption[] = [
229+
{ value: "enso", label: "Enso" },
214230
{ value: "enso-flash", label: "Enso Flash" },
231+
{ value: "enso-ultra", label: "Enso Ultra" },
215232
{ value: "zen5-coder", label: "Zen 5 Coder" },
216-
{ value: "enso", label: "Enso" },
217233
{ value: "zen5-flash", label: "Zen 5 Flash" },
218234
{ value: "zen5", label: "Zen 5" },
219235
{ value: "zen5-pro", label: "Zen 5 Pro" },

tests/integration/api/models.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,18 @@ describe("BFF: GET /v1/models", () => {
5050
expect(res.status).toBe(200);
5151
expect(body.ok).toBe(true);
5252
expect(body.fallback).toBe(true);
53-
// Offline default is DEFAULT_MODEL verbatim — enso-flash, the free rung of the
54-
// enso ladder — and it IS a FALLBACK_MODELS entry, so the offline default is
53+
// Offline default is DEFAULT_MODEL verbatim — bare `enso`, the rung that
54+
// picks best — and it IS a FALLBACK_MODELS entry, so the offline default is
5555
// selectable in the picker.
56-
expect(body.defaultModel).toBe("enso-flash");
57-
expect(body.models).toHaveLength(10);
56+
expect(body.defaultModel).toBe("enso");
57+
expect(body.models).toHaveLength(11);
5858
const offlineIds = body.models.map((m: { value: string }) => m.value);
59+
// The three enso rungs the family actually serves. `enso-pro` is listed by
60+
// ai from a pin but the family has no such SKU, so it must NOT appear.
61+
expect(offlineIds).toContain("enso");
5962
expect(offlineIds).toContain("enso-flash");
63+
expect(offlineIds).toContain("enso-ultra");
64+
expect(offlineIds).not.toContain("enso-pro");
6065
expect(offlineIds).toContain("zen5-coder");
6166
expect(res.headers.get("cache-control")).toBe("no-store");
6267
});
@@ -121,7 +126,7 @@ describe("BFF: GET /v1/models", () => {
121126
const body = await res.json();
122127
expect(res.status).toBe(200);
123128
expect(body.fallback).toBe(true);
124-
expect(body.models).toHaveLength(10);
129+
expect(body.models).toHaveLength(11);
125130
});
126131

127132
it("falls back (200) when the gateway serves no build models", async () => {

tests/unit/smart-routing.test.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import { resolveSmartRouting } from "../../lib/providers";
99
* server-driven org policy (null = unknown / older cloud-api).
1010
*/
1111

12-
test("fail-soft: no org policy → local preference only, default on", () => {
13-
// prior behavior: smart routing was the default when nothing is set
12+
test("fail-soft: no org policy → local preference only, default OFF", () => {
13+
// A fresh session with no org policy opens on DEFAULT_MODEL (Enso), not on
14+
// the separate `auto` router — otherwise the stated product default is never
15+
// what a new user actually gets.
1416
assert.deepEqual(resolveSmartRouting(null, null), {
15-
enabled: true,
17+
enabled: false,
1618
toggleDisabled: false,
1719
});
1820
assert.deepEqual(resolveSmartRouting(true, null), {

0 commit comments

Comments
 (0)