feat: add Hermes provider

This commit is contained in:
Simos Mikelatos
2026-06-30 09:51:18 +00:00
parent 2ebe64f218
commit 048c671b13
49 changed files with 2816 additions and 76 deletions

View File

@@ -39,6 +39,7 @@ interface UseChatComposerStateArgs {
codexModel: string;
geminiModel: string;
opencodeModel: string;
hermesModel: string;
isLoading: boolean;
canAbortSession: boolean;
tokenBudget: Record<string, unknown> | null;
@@ -173,6 +174,7 @@ export function useChatComposerState({
codexModel,
geminiModel,
opencodeModel,
hermesModel,
isLoading,
canAbortSession,
tokenBudget,
@@ -336,6 +338,8 @@ export function useChatComposerState({
? geminiModel
: provider === 'opencode'
? opencodeModel
: provider === 'hermes'
? (hermesModel === '__hermes_configured_model__' ? undefined : hermesModel)
: claudeModel,
tokenUsage: tokenBudget,
};
@@ -391,6 +395,7 @@ export function useChatComposerState({
cursorModel,
geminiModel,
opencodeModel,
hermesModel,
handleBuiltInCommand,
handleCustomCommand,
input,
@@ -703,6 +708,8 @@ export function useChatComposerState({
? 'gemini-settings'
: provider === 'opencode'
? 'opencode-settings'
: provider === 'hermes'
? 'hermes-settings'
: 'claude-settings';
const savedSettings = safeLocalStorage.getItem(settingsKey);
if (savedSettings) {
@@ -729,6 +736,8 @@ export function useChatComposerState({
? geminiModel
: provider === 'opencode'
? opencodeModel
: provider === 'hermes'
? (hermesModel === '__hermes_configured_model__' ? undefined : hermesModel)
: claudeModel;
// One message shape for every provider. The backend resolves the
@@ -774,6 +783,7 @@ export function useChatComposerState({
executeCommand,
geminiModel,
opencodeModel,
hermesModel,
isLoading,
onSessionProcessing,
onSessionEstablished,

View File

@@ -15,6 +15,7 @@ const FALLBACK_DEFAULT_MODEL: Record<LLMProvider, string> = {
codex: 'gpt-5.4',
gemini: 'gemini-3.1-pro-preview',
opencode: 'anthropic/claude-sonnet-4-5',
hermes: '__hermes_configured_model__',
};
/**
@@ -29,6 +30,7 @@ const FALLBACK_PERMISSION_MODES: Record<LLMProvider, PermissionMode[]> = {
codex: ['default', 'acceptEdits', 'bypassPermissions'],
gemini: ['default', 'acceptEdits', 'bypassPermissions', 'plan'],
opencode: ['default'],
hermes: ['default'],
};
type ProviderCapabilities = {
@@ -93,6 +95,9 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
const [opencodeModel, setOpenCodeModel] = useState<string>(() => {
return localStorage.getItem('opencode-model') || FALLBACK_DEFAULT_MODEL.opencode;
});
const [hermesModel, setHermesModel] = useState<string>(() => {
return localStorage.getItem('hermes-model') || FALLBACK_DEFAULT_MODEL.hermes;
});
/**
* Backend-owned capability matrix keyed by provider. Drives the permission
@@ -141,12 +146,20 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
return;
}
setOpenCodeModel(model);
localStorage.setItem('opencode-model', model);
if (targetProvider === 'opencode') {
setOpenCodeModel(model);
localStorage.setItem('opencode-model', model);
return;
}
if (targetProvider === 'hermes') {
setHermesModel(model);
localStorage.setItem('hermes-model', model);
}
}, []);
const loadProviderModels = useCallback(async (options: { bypassCache?: boolean } = {}) => {
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
const providers: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini', 'opencode', 'hermes'];
const requestId = providerModelsRequestIdRef.current + 1;
providerModelsRequestIdRef.current = requestId;
const isHardRefresh = options.bypassCache === true;
@@ -324,6 +337,19 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
}
}, [providerModelCatalog.opencode, opencodeModel]);
useEffect(() => {
const hermes = providerModelCatalog.hermes;
if (hermes) {
const next = pickStoredOrCurrent('hermes-model', hermesModel, hermes);
if (next !== hermesModel) {
setHermesModel(next);
}
if (localStorage.getItem('hermes-model') !== next) {
localStorage.setItem('hermes-model', next);
}
}
}, [providerModelCatalog.hermes, hermesModel]);
useEffect(() => {
if (!selectedSession?.id) {
return;
@@ -434,6 +460,8 @@ export function useChatProviderState({ selectedSession, selectedProject }: UseCh
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
permissionMode,
setPermissionMode,
pendingPermissionRequests,

View File

@@ -75,6 +75,8 @@ function ChatInterface({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
permissionMode,
pendingPermissionRequests,
setPendingPermissionRequests,
@@ -201,6 +203,7 @@ function ChatInterface({
codexModel,
geminiModel,
opencodeModel,
hermesModel,
isLoading: isProcessing,
canAbortSession,
tokenBudget,
@@ -293,7 +296,9 @@ function ChatInterface({
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude');
: provider === 'hermes'
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
: t('messageTypes.claude');
return (
<div className="flex h-full items-center justify-center">
@@ -334,6 +339,8 @@ function ChatInterface({
setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel}
hermesModel={hermesModel}
setHermesModel={setHermesModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
tasksEnabled={tasksEnabled}
@@ -425,7 +432,9 @@ function ChatInterface({
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: t('messageTypes.claude'),
: provider === 'hermes'
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
: t('messageTypes.claude'),
})}
isTextareaExpanded={isTextareaExpanded}
sendByCtrlEnter={sendByCtrlEnter}

View File

@@ -39,6 +39,8 @@ interface ChatMessagesPaneProps {
setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
hermesModel: string;
setHermesModel: (model: string) => void;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelsLoading: boolean;
tasksEnabled: boolean;
@@ -89,6 +91,8 @@ function ChatMessagesPane({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
providerModelCatalog,
providerModelsLoading,
tasksEnabled,
@@ -177,6 +181,8 @@ function ChatMessagesPane({
setGeminiModel={setGeminiModel}
opencodeModel={opencodeModel}
setOpenCodeModel={setOpenCodeModel}
hermesModel={hermesModel}
setHermesModel={setHermesModel}
providerModelCatalog={providerModelCatalog}
providerModelsLoading={providerModelsLoading}
tasksEnabled={tasksEnabled}

View File

@@ -63,6 +63,7 @@ const PROVIDER_LABELS: Record<string, string> = {
codex: 'Codex',
gemini: 'Gemini',
opencode: 'OpenCode',
hermes: 'Hermes',
};
const FALLBACK_COMMANDS: CommandEntry[] = [

View File

@@ -183,6 +183,8 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
? t('messageTypes.gemini')
: provider === 'opencode'
? t('messageTypes.opencode', { defaultValue: 'OpenCode' })
: provider === 'hermes'
? t('messageTypes.hermes', { defaultValue: 'Hermes' })
: t('messageTypes.claude'))}
</div>
</div>
@@ -430,4 +432,3 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, a
});
export default MessageComponent;

View File

@@ -29,6 +29,7 @@ const PROVIDER_META: { id: LLMProvider; name: string }[] = [
{ id: "gemini", name: "Google" },
{ id: "cursor", name: "Cursor" },
{ id: "opencode", name: "OpenCode" },
{ id: "hermes", name: "Hermes" },
];
const MOD_KEY =
@@ -50,6 +51,8 @@ type ProviderSelectionEmptyStateProps = {
setGeminiModel: (model: string) => void;
opencodeModel: string;
setOpenCodeModel: (model: string) => void;
hermesModel: string;
setHermesModel: (model: string) => void;
providerModelCatalog: Partial<Record<LLMProvider, ProviderModelsDefinition>>;
providerModelsLoading: boolean;
tasksEnabled: boolean;
@@ -79,11 +82,13 @@ function getCurrentModel(
co: string,
g: string,
o: string,
h: string,
) {
if (p === "claude") return c;
if (p === "codex") return co;
if (p === "gemini") return g;
if (p === "opencode") return o;
if (p === "hermes") return h;
return cu;
}
@@ -92,6 +97,7 @@ function getProviderDisplayName(p: LLMProvider) {
if (p === "cursor") return "Cursor";
if (p === "codex") return "Codex";
if (p === "opencode") return "OpenCode";
if (p === "hermes") return "Hermes";
return "Gemini";
}
@@ -111,6 +117,8 @@ export default function ProviderSelectionEmptyState({
setGeminiModel,
opencodeModel,
setOpenCodeModel,
hermesModel,
setHermesModel,
providerModelCatalog,
providerModelsLoading,
tasksEnabled,
@@ -140,6 +148,7 @@ export default function ProviderSelectionEmptyState({
codexModel,
geminiModel,
opencodeModel,
hermesModel,
);
const currentModelLabel = useMemo(() => {
@@ -164,12 +173,15 @@ export default function ProviderSelectionEmptyState({
} else if (providerId === "opencode") {
setOpenCodeModel(modelValue);
localStorage.setItem("opencode-model", modelValue);
} else if (providerId === "hermes") {
setHermesModel(modelValue);
localStorage.setItem("hermes-model", modelValue);
} else {
setCursorModel(modelValue);
localStorage.setItem("cursor-model", modelValue);
}
},
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel],
[setClaudeModel, setCursorModel, setCodexModel, setGeminiModel, setOpenCodeModel, setHermesModel],
);
const handleModelSelect = useCallback(
@@ -319,6 +331,10 @@ export default function ProviderSelectionEmptyState({
model: opencodeModel,
defaultValue: "Ready with OpenCode {{model}}",
}),
hermes: t("providerSelection.readyPrompt.hermes", {
model: hermesModel,
defaultValue: "Ready with Hermes {{model}}",
}),
}[provider]
}
</p>