From 2edbe0566d06b9fdad5e9649c95398439dbe14a8 Mon Sep 17 00:00:00 2001 From: cthomas Date: Wed, 4 Mar 2026 12:16:30 -0800 Subject: [PATCH] feat: add letta/auto and letta/auto-fast model support (#1259) Co-authored-by: Letta Code --- src/cli/components/ModelSelector.tsx | 31 ++++++++-- src/models.json | 14 +++++ .../agent/models-auto.integration.test.ts | 57 +++++++++++++++++++ .../cli/model-selector-availability.test.ts | 55 ++++++++++++++++++ 4 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 src/tests/agent/models-auto.integration.test.ts create mode 100644 src/tests/cli/model-selector-availability.test.ts diff --git a/src/cli/components/ModelSelector.tsx b/src/cli/components/ModelSelector.tsx index b35f9ac..a6c80e0 100644 --- a/src/cli/components/ModelSelector.tsx +++ b/src/cli/components/ModelSelector.tsx @@ -54,6 +54,27 @@ type UiModel = { updateArgs?: Record; }; +const API_GATED_MODEL_HANDLES = new Set(["letta/auto", "letta/auto-fast"]); + +export function filterModelsByAvailabilityForSelector< + T extends { handle: string }, +>( + typedModels: T[], + availableHandles: Set | null, + allApiHandles: string[], +): T[] { + if (availableHandles === null) { + return typedModels.filter((m) => { + if (!API_GATED_MODEL_HANDLES.has(m.handle)) { + return true; + } + return allApiHandles.includes(m.handle); + }); + } + + return typedModels.filter((m) => availableHandles.has(m.handle)); +} + interface ModelSelectorProps { currentModelId?: string; onSelect: (modelId: string) => void; @@ -181,10 +202,11 @@ export function ModelSelector({ const isFreeTier = billingTier?.toLowerCase() === "free"; const supportedModels = useMemo(() => { if (availableHandles === undefined) return []; - let available = - availableHandles === null - ? typedModels // fallback - : typedModels.filter((m) => availableHandles.has(m.handle)); + let available = filterModelsByAvailabilityForSelector( + typedModels, + availableHandles, + allApiHandles, + ); // Apply provider filter if specified if (filterProvider) { available = available.filter((m) => @@ -228,6 +250,7 @@ export function ModelSelector({ }, [ typedModels, availableHandles, + allApiHandles, filterProvider, searchQuery, isFreeTier, diff --git a/src/models.json b/src/models.json index f81af55..6380194 100644 --- a/src/models.json +++ b/src/models.json @@ -1,4 +1,18 @@ [ + { + "id": "auto", + "handle": "letta/auto", + "label": "Auto", + "description": "Automatically select the best model", + "isFeatured": true + }, + { + "id": "auto-fast", + "handle": "letta/auto-fast", + "label": "Auto Fast", + "description": "Automatically select the best fast model", + "isFeatured": true + }, { "id": "sonnet", "handle": "anthropic/claude-sonnet-4-6", diff --git a/src/tests/agent/models-auto.integration.test.ts b/src/tests/agent/models-auto.integration.test.ts new file mode 100644 index 0000000..6b4a230 --- /dev/null +++ b/src/tests/agent/models-auto.integration.test.ts @@ -0,0 +1,57 @@ +/** + * Live API regression test for Cloud model availability. + * + * Runs only when: + * - LETTA_API_KEY is set + * - LETTA_BASE_URL points to Letta Cloud (api.letta.com) + */ + +import { describe, expect, test } from "bun:test"; +import Letta from "@letta-ai/letta-client"; + +const LETTA_API_KEY = process.env.LETTA_API_KEY; +const LETTA_BASE_URL = process.env.LETTA_BASE_URL || "https://api.letta.com"; + +function isCloudBaseUrl(value: string): boolean { + try { + return new URL(value).hostname === "api.letta.com"; + } catch { + return value.includes("api.letta.com"); + } +} + +const describeIntegration = + LETTA_API_KEY && isCloudBaseUrl(LETTA_BASE_URL) ? describe : describe.skip; + +async function listModelHandlesWithRetry( + client: Letta, + maxAttempts = 3, +): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const models = await client.models.list(); + return models.map((m) => m.handle).filter((h): h is string => Boolean(h)); + } catch (error) { + lastError = error; + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 500 * attempt)); + } + } + } + + throw lastError; +} + +describeIntegration("cloud models list", () => { + test("includes letta/auto handle", async () => { + const client = new Letta({ + baseURL: LETTA_BASE_URL, + apiKey: LETTA_API_KEY, + }); + + const handles = await listModelHandlesWithRetry(client); + expect(handles).toContain("letta/auto"); + }); +}); diff --git a/src/tests/cli/model-selector-availability.test.ts b/src/tests/cli/model-selector-availability.test.ts new file mode 100644 index 0000000..4b49e80 --- /dev/null +++ b/src/tests/cli/model-selector-availability.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { filterModelsByAvailabilityForSelector } from "../../cli/components/ModelSelector"; + +type StubModel = { handle: string; label: string }; + +const MODELS: StubModel[] = [ + { handle: "letta/auto", label: "Auto" }, + { handle: "letta/auto-fast", label: "Auto Fast" }, + { handle: "anthropic/claude-sonnet-4-6", label: "Sonnet 4.6" }, +]; + +describe("ModelSelector availability gating", () => { + test("includes letta/auto when API availability includes it", () => { + const availableHandles = new Set([ + "letta/auto", + "anthropic/claude-sonnet-4-6", + ]); + + const result = filterModelsByAvailabilityForSelector( + MODELS, + availableHandles, + Array.from(availableHandles), + ); + + expect(result.map((m) => m.handle)).toContain("letta/auto"); + }); + + test("excludes letta/auto when API availability does not include it", () => { + const availableHandles = new Set(["anthropic/claude-sonnet-4-6"]); + + const result = filterModelsByAvailabilityForSelector( + MODELS, + availableHandles, + Array.from(availableHandles), + ); + + expect(result.map((m) => m.handle)).not.toContain("letta/auto"); + }); + + test("fallback mode hides letta/auto unless explicitly present in allApiHandles", () => { + const hiddenResult = filterModelsByAvailabilityForSelector(MODELS, null, [ + "anthropic/claude-sonnet-4-6", + ]); + expect(hiddenResult.map((m) => m.handle)).not.toContain("letta/auto"); + expect(hiddenResult.map((m) => m.handle)).toContain( + "anthropic/claude-sonnet-4-6", + ); + + const shownResult = filterModelsByAvailabilityForSelector(MODELS, null, [ + "letta/auto", + "anthropic/claude-sonnet-4-6", + ]); + expect(shownResult.map((m) => m.handle)).toContain("letta/auto"); + }); +});