Byok support (#277)

This commit is contained in:
Ari Webb
2025-12-18 09:49:20 -08:00
committed by GitHub
parent 9589ce177c
commit 407da24c5b
3 changed files with 165 additions and 101 deletions

View File

@@ -97,7 +97,7 @@ function buildModelSettings(
}
settings = googleVertexSettings;
} else {
// For unknown providers, return generic settings with parallel_tool_calls
// For BYOK/unknown providers, return generic settings with parallel_tool_calls
settings = { parallel_tool_calls: true };
}
@@ -130,14 +130,13 @@ export async function updateAgentLLMConfig(
const modelSettings = buildModelSettings(modelHandle, updateArgs);
const contextWindow = updateArgs?.context_window as number | undefined;
const hasModelSettings = Object.keys(modelSettings).length > 0;
if (modelSettings || contextWindow) {
await client.agents.update(agentId, {
model: modelHandle,
...(modelSettings && { model_settings: modelSettings }),
...(contextWindow && { context_window_limit: contextWindow }),
});
}
await client.agents.update(agentId, {
model: modelHandle,
...(hasModelSettings && { model_settings: modelSettings }),
...(contextWindow && { context_window_limit: contextWindow }),
});
const finalAgent = await client.agents.retrieve(agentId);
return finalAgent.llm_config;

View File

@@ -433,6 +433,7 @@ export default function App({
| null
>(null);
const [llmConfig, setLlmConfig] = useState<LlmConfig | null>(null);
const [currentModelId, setCurrentModelId] = useState<string | null>(null);
const [agentName, setAgentName] = useState<string | null>(null);
const [agentDescription, setAgentDescription] = useState<string | null>(null);
const [agentLastRunAt, setAgentLastRunAt] = useState<string | null>(null);
@@ -3514,7 +3515,18 @@ ${recentCommits}
try {
// Find the selected model from models.json first (for loading message)
const { models } = await import("../agent/model");
const selectedModel = models.find((m) => m.id === modelId);
let selectedModel = models.find((m) => m.id === modelId);
// If not found in static list, it might be a BYOK model where id === handle
if (!selectedModel && modelId.includes("/")) {
// Treat it as a BYOK model - the modelId is actually the handle
selectedModel = {
id: modelId,
handle: modelId,
label: modelId.split("/").pop() ?? modelId,
description: "Custom model",
} as unknown as (typeof models)[number];
}
if (!selectedModel) {
// Create a failed command in the transcript
@@ -3553,6 +3565,7 @@ ${recentCommits}
selectedModel.updateArgs,
);
setLlmConfig(updatedConfig);
setCurrentModelId(modelId);
// After switching models, only switch toolset if it actually changes
const { isOpenAIModel, isGeminiModel } = await import(
@@ -4319,12 +4332,7 @@ Plan file path: ${planFilePath}`;
{/* Model Selector - conditionally mounted as overlay */}
{activeOverlay === "model" && (
<ModelSelector
currentModel={
llmConfig?.model_endpoint_type && llmConfig?.model
? `${llmConfig.model_endpoint_type}/${llmConfig.model}`
: undefined
}
currentEnableReasoner={llmConfig?.enable_reasoner}
currentModelId={currentModelId ?? undefined}
onSelect={handleModelSelect}
onCancel={closeOverlay}
/>

View File

@@ -1,6 +1,6 @@
// Import useInput from vendored Ink for bracketed paste support
import { Box, Text, useInput } from "ink";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
clearAvailableModelsCache,
getAvailableModelHandles,
@@ -9,6 +9,11 @@ import {
import { models } from "../../agent/model";
import { colors } from "./colors";
const PAGE_SIZE = 10;
type ModelCategory = "supported" | "all";
const MODEL_CATEGORIES: ModelCategory[] = ["supported", "all"];
type UiModel = {
id: string;
handle: string;
@@ -20,27 +25,28 @@ type UiModel = {
};
interface ModelSelectorProps {
currentModel?: string;
currentEnableReasoner?: boolean;
currentModelId?: string;
onSelect: (modelId: string) => void;
onCancel: () => void;
}
export function ModelSelector({
currentModel,
currentEnableReasoner,
currentModelId,
onSelect,
onCancel,
}: ModelSelectorProps) {
const typedModels = models as UiModel[];
const [showAll, setShowAll] = useState(false);
const [category, setCategory] = useState<ModelCategory>("supported");
const [currentPage, setCurrentPage] = useState(0);
const [selectedIndex, setSelectedIndex] = useState(0);
// undefined: not loaded yet (show spinner)
// Set<string>: loaded and filtered
// null: error fallback (show all models + warning)
const [availableModels, setAvailableModels] = useState<
const [availableHandles, setAvailableHandles] = useState<
Set<string> | null | undefined
>(undefined);
const [allApiHandles, setAllApiHandles] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isCached, setIsCached] = useState(false);
@@ -70,7 +76,8 @@ export function ModelSelector({
if (!mountedRef.current) return;
setAvailableModels(result.handles);
setAvailableHandles(result.handles);
setAllApiHandles(Array.from(result.handles));
setIsCached(!forceRefresh && cacheInfoBefore.isFresh);
setIsLoading(false);
setRefreshing(false);
@@ -80,7 +87,8 @@ export function ModelSelector({
setIsLoading(false);
setRefreshing(false);
// Fallback: show all models if API fails
setAvailableModels(null);
setAvailableHandles(null);
setAllApiHandles([]);
}
});
@@ -88,44 +96,79 @@ export function ModelSelector({
loadModels.current(false);
}, []);
// Filter models based on availability
const filteredModels = useMemo(() => {
// Not loaded yet: render nothing (avoid briefly showing unfiltered models)
if (availableModels === undefined) return [];
// Error fallback: show all models with warning
if (availableModels === null) return typedModels;
// Loaded: filter to only show models the user has access to
return typedModels.filter((model) => availableModels.has(model.handle));
}, [typedModels, availableModels]);
// Handles from models.json (for filtering "all" category)
const staticModelHandles = useMemo(
() => new Set(typedModels.map((m) => m.handle)),
[typedModels],
);
const featuredModels = useMemo(
() => filteredModels.filter((model) => model.isFeatured),
[filteredModels],
// Supported models: models.json entries that are available
const supportedModels = useMemo(() => {
if (availableHandles === undefined) return [];
if (availableHandles === null) return typedModels; // fallback
return typedModels.filter((m) => availableHandles.has(m.handle));
}, [typedModels, availableHandles]);
// All other models: API handles not in models.json
const otherModelHandles = useMemo(() => {
return allApiHandles.filter((handle) => !staticModelHandles.has(handle));
}, [allApiHandles, staticModelHandles]);
// Get the list for current category
const currentList: UiModel[] = useMemo(() => {
if (category === "supported") {
return supportedModels;
}
// For "all" category, convert handles to simple UiModel objects
return otherModelHandles.map((handle) => ({
id: handle,
handle,
label: handle,
description: "",
}));
}, [category, supportedModels, otherModelHandles]);
// Pagination
const totalPages = useMemo(
() => Math.max(1, Math.ceil(currentList.length / PAGE_SIZE)),
[currentList.length],
);
const visibleModels = useMemo(() => {
if (showAll) return filteredModels;
if (featuredModels.length > 0) return featuredModels;
return filteredModels.slice(0, 5);
}, [featuredModels, showAll, filteredModels]);
const start = currentPage * PAGE_SIZE;
return currentList.slice(start, start + PAGE_SIZE);
}, [currentList, currentPage]);
// Reset page and selection when category changes
const cycleCategory = useCallback(() => {
setCategory((current) => {
const idx = MODEL_CATEGORIES.indexOf(current);
return MODEL_CATEGORIES[
(idx + 1) % MODEL_CATEGORIES.length
] as ModelCategory;
});
setCurrentPage(0);
setSelectedIndex(0);
}, []);
// Set initial selection to current model on mount
const initializedRef = useRef(false);
useEffect(() => {
if (!initializedRef.current) {
const index = visibleModels.findIndex((m) => m.handle === currentModel);
if (!initializedRef.current && visibleModels.length > 0) {
const index = visibleModels.findIndex((m) => m.id === currentModelId);
if (index >= 0) {
setSelectedIndex(index);
}
initializedRef.current = true;
}
}, [visibleModels, currentModel]);
}, [visibleModels, currentModelId]);
const hasMoreModels =
!showAll && filteredModels.length > visibleModels.length;
const totalItems = hasMoreModels
? visibleModels.length + 1
: visibleModels.length;
// Clamp selectedIndex when list changes
useEffect(() => {
if (selectedIndex >= visibleModels.length && visibleModels.length > 0) {
setSelectedIndex(visibleModels.length - 1);
}
}, [selectedIndex, visibleModels.length]);
useInput(
(input, key) => {
@@ -141,6 +184,11 @@ export function ModelSelector({
return;
}
if (key.tab) {
cycleCategory();
return;
}
// Disable other inputs while loading
if (isLoading || refreshing || visibleModels.length === 0) {
return;
@@ -149,16 +197,31 @@ export function ModelSelector({
if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(totalItems - 1, prev + 1));
} else if (key.return) {
if (hasMoreModels && selectedIndex === visibleModels.length) {
setShowAll(true);
setSelectedIndex((prev) =>
Math.min(visibleModels.length - 1, prev + 1),
);
} else if (input === "j" || input === "J") {
// Previous page
if (currentPage > 0) {
setCurrentPage((prev) => prev - 1);
setSelectedIndex(0);
} else {
const selectedModel = visibleModels[selectedIndex];
if (selectedModel) {
onSelect(selectedModel.id);
}
}
} else if (input === "k" || input === "K") {
// Next page
if (currentPage < totalPages - 1) {
setCurrentPage((prev) => prev + 1);
setSelectedIndex(0);
}
} else if (key.leftArrow && currentPage > 0) {
setCurrentPage((prev) => prev - 1);
setSelectedIndex(0);
} else if (key.rightArrow && currentPage < totalPages - 1) {
setCurrentPage((prev) => prev + 1);
setSelectedIndex(0);
} else if (key.return) {
const selectedModel = visibleModels[selectedIndex];
if (selectedModel) {
onSelect(selectedModel.id);
}
}
},
@@ -166,17 +229,42 @@ export function ModelSelector({
{ isActive: true },
);
const getCategoryLabel = (cat: ModelCategory) => {
if (cat === "supported") return `Supported (${supportedModels.length})`;
return `All Available Models (${otherModelHandles.length})`;
};
return (
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Text bold color={colors.selector.title}>
Select Model ( to navigate, Enter to select, ESC to cancel)
Select Model ( navigate, /jk page, Enter select, ESC cancel)
</Text>
{!isLoading && !refreshing && (
<Box>
<Text dimColor>Category: </Text>
{MODEL_CATEGORIES.map((cat, i) => (
<Text key={cat}>
{i > 0 && <Text dimColor> · </Text>}
<Text
bold={cat === category}
color={
cat === category
? colors.selector.itemHighlighted
: undefined
}
>
{getCategoryLabel(cat)}
</Text>
</Text>
))}
<Text dimColor> (Tab to switch)</Text>
</Box>
)}
{!isLoading && !refreshing && (
<Text dimColor>
{isCached
? "Cached models (press 'r' to refresh)"
: "Press 'r' to refresh"}
Page {currentPage + 1}/{totalPages}
{isCached ? " · cached" : ""} · 'r' to refresh
</Text>
)}
</Box>
@@ -201,10 +289,12 @@ export function ModelSelector({
</Box>
)}
{!isLoading && visibleModels.length === 0 && (
{!isLoading && !refreshing && visibleModels.length === 0 && (
<Box>
<Text color="red">
No models available. Please check your Letta configuration.
<Text dimColor>
{category === "supported"
? "No supported models available."
: "No additional models available."}
</Text>
</Box>
)}
@@ -212,26 +302,7 @@ export function ModelSelector({
<Box flexDirection="column">
{visibleModels.map((model, index) => {
const isSelected = index === selectedIndex;
// Check if this model is current by comparing handle and relevant settings
let isCurrent = model.handle === currentModel;
// For models with the same handle, also check specific configuration settings
if (isCurrent && model.handle?.startsWith("anthropic/")) {
// For Anthropic models, check enable_reasoner setting
const modelEnableReasoner = model.updateArgs?.enable_reasoner;
// If the model explicitly sets enable_reasoner, check if it matches current settings
if (modelEnableReasoner !== undefined) {
// Model has explicit enable_reasoner setting, compare with current
isCurrent =
isCurrent && modelEnableReasoner === currentEnableReasoner;
} else {
// If model doesn't explicitly set enable_reasoner, it defaults to enabled (or undefined)
// It's current if currentEnableReasoner is not explicitly false
isCurrent = isCurrent && currentEnableReasoner !== false;
}
}
const isCurrent = model.id === currentModelId;
return (
<Box key={model.id} flexDirection="row" gap={1}>
@@ -252,27 +323,13 @@ export function ModelSelector({
<Text color={colors.selector.itemCurrent}> (current)</Text>
)}
</Text>
<Text dimColor> {model.description}</Text>
{model.description && (
<Text dimColor> {model.description}</Text>
)}
</Box>
</Box>
);
})}
{!showAll && filteredModels.length > visibleModels.length && (
<Box flexDirection="row" gap={1}>
<Text
color={
selectedIndex === visibleModels.length
? colors.selector.itemHighlighted
: undefined
}
>
{selectedIndex === visibleModels.length ? "" : " "}
</Text>
<Text dimColor>
Show all models ({filteredModels.length} available)
</Text>
</Box>
)}
</Box>
</Box>
);