Add OpenHands provider for LLM through OH Cloud (#9526)

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Xingyao Wang
2025-07-14 13:44:49 -04:00
committed by GitHub
parent 127220dc39
commit 6e25d4bbb6
18 changed files with 738 additions and 246 deletions

View File

@@ -82,17 +82,5 @@ describe("extractModelAndProvider", () => {
model: "claude-opus-4-20250514",
separator: "/",
});
expect(extractModelAndProvider("claude-3-haiku-20240307")).toEqual({
provider: "anthropic",
model: "claude-3-haiku-20240307",
separator: "/",
});
expect(extractModelAndProvider("claude-2.1")).toEqual({
provider: "anthropic",
model: "claude-2.1",
separator: "/",
});
});
});

View File

@@ -52,14 +52,16 @@ test("organizeModelsAndProviders", () => {
separator: "/",
models: [
"claude-3-5-sonnet-20241022",
],
},
other: {
separator: "",
models: [
"together-ai-21.1b-41b",
"claude-3-haiku-20240307",
"claude-2",
"claude-2.1",
],
},
other: {
separator: "",
models: ["together-ai-21.1b-41b"],
},
});
});

View File

@@ -1,19 +1,215 @@
import React, { useState } from "react";
import { useTranslation, Trans } from "react-i18next";
import { FaTrash } from "react-icons/fa6";
import { FaTrash, FaEye, FaEyeSlash, FaCopy } from "react-icons/fa6";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ApiKey, CreateApiKeyResponse } from "#/api/api-keys";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { CreateApiKeyModal } from "./create-api-key-modal";
import { DeleteApiKeyModal } from "./delete-api-key-modal";
import { NewApiKeyModal } from "./new-api-key-modal";
import { useApiKeys } from "#/hooks/query/use-api-keys";
import {
useLlmApiKey,
useRefreshLlmApiKey,
} from "#/hooks/query/use-llm-api-key";
interface LlmApiKeyManagerProps {
llmApiKey: { key: string | null } | undefined;
isLoadingLlmKey: boolean;
refreshLlmApiKey: ReturnType<typeof useRefreshLlmApiKey>;
}
function LlmApiKeyManager({
llmApiKey,
isLoadingLlmKey,
refreshLlmApiKey,
}: LlmApiKeyManagerProps) {
const { t } = useTranslation();
const [showLlmApiKey, setShowLlmApiKey] = useState(false);
const handleRefreshLlmApiKey = () => {
refreshLlmApiKey.mutate(undefined, {
onSuccess: () => {
displaySuccessToast(
t(I18nKey.SETTINGS$API_KEY_REFRESHED, {
defaultValue: "API key refreshed successfully",
}),
);
},
onError: () => {
displayErrorToast(t(I18nKey.ERROR$GENERIC));
},
});
};
if (isLoadingLlmKey || !llmApiKey) {
return null;
}
return (
<div className="border-b border-gray-200 pb-6 mb-6 flex flex-col gap-6">
<h3 className="text-xl font-medium text-white">
{t(I18nKey.SETTINGS$LLM_API_KEY)}
</h3>
<div className="flex items-center justify-between">
<BrandButton
type="button"
variant="primary"
onClick={handleRefreshLlmApiKey}
isDisabled={refreshLlmApiKey.isPending}
>
{refreshLlmApiKey.isPending ? (
<LoadingSpinner size="small" />
) : (
t(I18nKey.SETTINGS$REFRESH_LLM_API_KEY)
)}
</BrandButton>
</div>
<div>
<p className="text-sm text-gray-300 mb-2">
{t(I18nKey.SETTINGS$LLM_API_KEY_DESCRIPTION)}
</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-base-tertiary rounded-md py-2 flex items-center">
<div className="flex-1">
{llmApiKey.key ? (
<div className="flex items-center">
{showLlmApiKey ? (
<span className="text-white font-mono">
{llmApiKey.key}
</span>
) : (
<span className="text-white">{"•".repeat(20)}</span>
)}
</div>
) : (
<span className="text-white">
{t(I18nKey.API$NO_KEY_AVAILABLE)}
</span>
)}
</div>
<div className="flex items-center">
{llmApiKey.key && (
<button
type="button"
className="text-white hover:text-gray-300 mr-2"
aria-label={showLlmApiKey ? "Hide API key" : "Show API key"}
title={showLlmApiKey ? "Hide API key" : "Show API key"}
onClick={() => setShowLlmApiKey(!showLlmApiKey)}
>
{showLlmApiKey ? (
<FaEyeSlash size={20} />
) : (
<FaEye size={20} />
)}
</button>
)}
<button
type="button"
className="text-white hover:text-gray-300 mr-2"
aria-label="Copy API key"
title="Copy API key"
onClick={() => {
if (llmApiKey.key) {
navigator.clipboard.writeText(llmApiKey.key);
displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_COPIED));
}
}}
>
<FaCopy size={20} />
</button>
</div>
</div>
</div>
</div>
</div>
);
}
interface ApiKeysTableProps {
apiKeys: ApiKey[];
isLoading: boolean;
onDeleteKey: (key: ApiKey) => void;
}
function ApiKeysTable({ apiKeys, isLoading, onDeleteKey }: ApiKeysTableProps) {
const { t } = useTranslation();
const formatDate = (dateString: string | null) => {
if (!dateString) return "Never";
return new Date(dateString).toLocaleString();
};
if (isLoading) {
return (
<div className="flex justify-center p-4">
<LoadingSpinner size="large" />
</div>
);
}
if (!Array.isArray(apiKeys) || apiKeys.length === 0) {
return null;
}
return (
<div className="border border-tertiary rounded-md overflow-hidden">
<table className="w-full">
<thead className="bg-base-tertiary">
<tr>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$NAME)}
</th>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$CREATED_AT)}
</th>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$LAST_USED)}
</th>
<th className="text-right p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$ACTIONS)}
</th>
</tr>
</thead>
<tbody>
{apiKeys.map((key) => (
<tr key={key.id} className="border-t border-tertiary">
<td
className="p-3 text-sm truncate max-w-[160px]"
title={key.name}
>
{key.name}
</td>
<td className="p-3 text-sm">{formatDate(key.created_at)}</td>
<td className="p-3 text-sm">{formatDate(key.last_used_at)}</td>
<td className="p-3 text-right">
<button
type="button"
onClick={() => onDeleteKey(key)}
aria-label={`Delete ${key.name}`}
className="cursor-pointer"
>
<FaTrash size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function ApiKeysManager() {
const { t } = useTranslation();
const { data: apiKeys = [], isLoading, error } = useApiKeys();
const { data: llmApiKey, isLoading: isLoadingLlmKey } = useLlmApiKey();
const refreshLlmApiKey = useRefreshLlmApiKey();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [keyToDelete, setKeyToDelete] = useState<ApiKey | null>(null);
@@ -46,14 +242,24 @@ export function ApiKeysManager() {
setNewlyCreatedKey(null);
};
const formatDate = (dateString: string | null) => {
if (!dateString) return "Never";
return new Date(dateString).toLocaleString();
const handleDeleteKey = (key: ApiKey) => {
setKeyToDelete(key);
setDeleteModalOpen(true);
};
return (
<>
<div className="flex flex-col gap-6">
<LlmApiKeyManager
llmApiKey={llmApiKey}
isLoadingLlmKey={isLoadingLlmKey}
refreshLlmApiKey={refreshLlmApiKey}
/>
<h3 className="text-xl font-medium text-white">
{t(I18nKey.SETTINGS$OPENHANDS_API_KEYS)}
</h3>
<div className="flex items-center justify-between">
<BrandButton
type="button"
@@ -82,64 +288,11 @@ export function ApiKeysManager() {
/>
</p>
{isLoading && (
<div className="flex justify-center p-4">
<LoadingSpinner size="large" />
</div>
)}
{!isLoading && Array.isArray(apiKeys) && apiKeys.length > 0 && (
<div className="border border-tertiary rounded-md overflow-hidden">
<table className="w-full">
<thead className="bg-base-tertiary">
<tr>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$NAME)}
</th>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$CREATED_AT)}
</th>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$LAST_USED)}
</th>
<th className="text-right p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$ACTIONS)}
</th>
</tr>
</thead>
<tbody>
{apiKeys.map((key) => (
<tr key={key.id} className="border-t border-tertiary">
<td
className="p-3 text-sm truncate max-w-[160px]"
title={key.name}
>
{key.name}
</td>
<td className="p-3 text-sm">
{formatDate(key.created_at)}
</td>
<td className="p-3 text-sm">
{formatDate(key.last_used_at)}
</td>
<td className="p-3 text-right">
<button
type="button"
onClick={() => {
setKeyToDelete(key);
setDeleteModalOpen(true);
}}
aria-label={`Delete ${key.name}`}
className="cursor-pointer"
>
<FaTrash size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<ApiKeysTable
apiKeys={apiKeys}
isLoading={isLoading}
onDeleteKey={handleDeleteKey}
/>
</div>
{/* Create API Key Modal */}

View File

@@ -3,9 +3,16 @@ interface HelpLinkProps {
text: string;
linkText: string;
href: string;
suffix?: string;
}
export function HelpLink({ testId, text, linkText, href }: HelpLinkProps) {
export function HelpLink({
testId,
text,
linkText,
href,
suffix,
}: HelpLinkProps) {
return (
<p data-testid={testId} className="text-xs">
{text}{" "}
@@ -17,6 +24,7 @@ export function HelpLink({ testId, text, linkText, href }: HelpLinkProps) {
>
{linkText}
</a>
{suffix && ` ${suffix}`}
</p>
);
}

View File

@@ -38,7 +38,6 @@ export function SettingsSwitch({
type="checkbox"
onChange={(e) => handleToggle(e.target.checked)}
checked={controlledIsToggled ?? isToggled}
defaultChecked={defaultIsToggled}
/>
<StyledSwitchComponent isToggled={controlledIsToggled ?? isToggled} />

View File

@@ -97,26 +97,30 @@ export function ModelSelector({
}}
>
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$VERIFIED)}>
{Object.keys(models)
.filter((provider) => VERIFIED_PROVIDERS.includes(provider))
.map((provider) => (
{VERIFIED_PROVIDERS.filter((provider) => models[provider]).map(
(provider) => (
<AutocompleteItem
data-testid={`provider-item-${provider}`}
key={provider}
>
{mapProvider(provider)}
</AutocompleteItem>
))}
</AutocompleteSection>
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
{Object.keys(models)
.filter((provider) => !VERIFIED_PROVIDERS.includes(provider))
.map((provider) => (
<AutocompleteItem key={provider}>
{mapProvider(provider)}
</AutocompleteItem>
))}
),
)}
</AutocompleteSection>
{Object.keys(models).some(
(provider) => !VERIFIED_PROVIDERS.includes(provider),
) ? (
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
{Object.keys(models)
.filter((provider) => !VERIFIED_PROVIDERS.includes(provider))
.map((provider) => (
<AutocompleteItem key={provider}>
{mapProvider(provider)}
</AutocompleteItem>
))}
</AutocompleteSection>
) : null}
</Autocomplete>
</fieldset>
@@ -147,24 +151,28 @@ export function ModelSelector({
}}
>
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$VERIFIED)}>
{models[selectedProvider || ""]?.models
.filter((model) => VERIFIED_MODELS.includes(model))
.map((model) => (
<AutocompleteItem key={model}>{model}</AutocompleteItem>
))}
</AutocompleteSection>
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
{models[selectedProvider || ""]?.models
.filter((model) => !VERIFIED_MODELS.includes(model))
.map((model) => (
<AutocompleteItem
data-testid={`model-item-${model}`}
key={model}
>
{model}
</AutocompleteItem>
))}
{VERIFIED_MODELS.filter((model) =>
models[selectedProvider || ""]?.models?.includes(model),
).map((model) => (
<AutocompleteItem key={model}>{model}</AutocompleteItem>
))}
</AutocompleteSection>
{models[selectedProvider || ""]?.models?.some(
(model) => !VERIFIED_MODELS.includes(model),
) ? (
<AutocompleteSection title={t(I18nKey.MODEL_SELECTOR$OTHERS)}>
{models[selectedProvider || ""]?.models
.filter((model) => !VERIFIED_MODELS.includes(model))
.map((model) => (
<AutocompleteItem
data-testid={`model-item-${model}`}
key={model}
>
{model}
</AutocompleteItem>
))}
</AutocompleteSection>
) : null}
</Autocomplete>
</fieldset>
</div>

View File

@@ -0,0 +1,42 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { openHands } from "#/api/open-hands-axios";
import { useConfig } from "./use-config";
export const LLM_API_KEY_QUERY_KEY = "llm-api-key";
export interface LlmApiKeyResponse {
key: string | null;
}
export function useLlmApiKey() {
const { data: config } = useConfig();
return useQuery({
queryKey: [LLM_API_KEY_QUERY_KEY],
enabled: config?.APP_MODE === "saas",
queryFn: async () => {
const { data } =
await openHands.get<LlmApiKeyResponse>("/api/keys/llm/byor");
return data;
},
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
}
export function useRefreshLlmApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const { data } = await openHands.post<LlmApiKeyResponse>(
"/api/keys/llm/byor/refresh",
);
return data;
},
onSuccess: () => {
// Invalidate the LLM API key query to trigger a refetch
queryClient.invalidateQueries({ queryKey: [LLM_API_KEY_QUERY_KEY] });
},
});
}

View File

@@ -369,6 +369,9 @@ export enum I18nKey {
SETTINGS$LANGUAGE_TOOLTIP = "SETTINGS$LANGUAGE_TOOLTIP",
SETTINGS$DISABLED_RUNNING = "SETTINGS$DISABLED_RUNNING",
SETTINGS$API_KEY_PLACEHOLDER = "SETTINGS$API_KEY_PLACEHOLDER",
SETTINGS$LLM_API_KEY = "SETTINGS$LLM_API_KEY",
SETTINGS$LLM_API_KEY_DESCRIPTION = "SETTINGS$LLM_API_KEY_DESCRIPTION",
SETTINGS$REFRESH_LLM_API_KEY = "SETTINGS$REFRESH_LLM_API_KEY",
SETTINGS$CONFIRMATION_MODE = "SETTINGS$CONFIRMATION_MODE",
SETTINGS$CONFIRMATION_MODE_TOOLTIP = "SETTINGS$CONFIRMATION_MODE_TOOLTIP",
SETTINGS$AGENT_SELECT_ENABLED = "SETTINGS$AGENT_SELECT_ENABLED",
@@ -381,6 +384,9 @@ export enum I18nKey {
SETTINGS$RESET = "SETTINGS$RESET",
SETTINGS$API_KEYS = "SETTINGS$API_KEYS",
SETTINGS$API_KEYS_DESCRIPTION = "SETTINGS$API_KEYS_DESCRIPTION",
SETTINGS$OPENHANDS_API_KEY_HELP = "SETTINGS$OPENHANDS_API_KEY_HELP",
SETTINGS$OPENHANDS_API_KEY_HELP_TEXT = "SETTINGS$OPENHANDS_API_KEY_HELP_TEXT",
SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX = "SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX",
SETTINGS$CREATE_API_KEY = "SETTINGS$CREATE_API_KEY",
SETTINGS$CREATE_API_KEY_DESCRIPTION = "SETTINGS$CREATE_API_KEY_DESCRIPTION",
SETTINGS$DELETE_API_KEY = "SETTINGS$DELETE_API_KEY",
@@ -396,6 +402,7 @@ export enum I18nKey {
SETTINGS$API_KEY_DELETED = "SETTINGS$API_KEY_DELETED",
SETTINGS$API_KEY_WARNING = "SETTINGS$API_KEY_WARNING",
SETTINGS$API_KEY_COPIED = "SETTINGS$API_KEY_COPIED",
SETTINGS$API_KEY_REFRESHED = "SETTINGS$API_KEY_REFRESHED",
SETTINGS$API_KEY_NAME_PLACEHOLDER = "SETTINGS$API_KEY_NAME_PLACEHOLDER",
BUTTON$CREATE = "BUTTON$CREATE",
BUTTON$DELETE = "BUTTON$DELETE",
@@ -665,6 +672,7 @@ export enum I18nKey {
API$TAVILY_KEY_EXAMPLE = "API$TAVILY_KEY_EXAMPLE",
API$TVLY_KEY_EXAMPLE = "API$TVLY_KEY_EXAMPLE",
SECRETS$CONNECT_GIT_PROVIDER = "SECRETS$CONNECT_GIT_PROVIDER",
SETTINGS$OPENHANDS_API_KEYS = "SETTINGS$OPENHANDS_API_KEYS",
CONVERSATION$BUDGET_USAGE_FORMAT = "CONVERSATION$BUDGET_USAGE_FORMAT",
CONVERSATION$CACHE_HIT = "CONVERSATION$CACHE_HIT",
CONVERSATION$CACHE_WRITE = "CONVERSATION$CACHE_WRITE",
@@ -674,4 +682,5 @@ export enum I18nKey {
FORM$DESCRIPTION = "FORM$DESCRIPTION",
COMMON$OPTIONAL = "COMMON$OPTIONAL",
BROWSER$SERVER_MESSAGE = "BROWSER$SERVER_MESSAGE",
API$NO_KEY_AVAILABLE = "API$NO_KEY_AVAILABLE",
}

View File

@@ -15,7 +15,6 @@
"de": "Kein Repository gefunden, um Microagent zu starten",
"uk": "Не знайдено репозиторій для запуску мікроагента"
},
"MICROAGENT$ADD_TO_MICROAGENT": {
"en": "Add to Microagent",
"ja": "マイクロエージェントに追加",
@@ -4384,7 +4383,7 @@
"ja": "設定を更新しました",
"uk": "Налаштування оновлено"
},
"CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE":{
"CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE": {
"en": "NEW FILES ADDED",
"de": "NEUE DATEIEN HINZUGEFÜGT",
"zh-CN": "已添加新文件",
@@ -4398,8 +4397,8 @@
"fr": "NOUVEAUX FICHIERS AJOUTÉS",
"tr": "YENİ DOSYALAR EKLENDİ",
"ja": "新しいファイルが追加されました",
"uk": "ДОДАНО НОВІ ФАЙЛИ"
},
"uk": "ДОДАНО НОВІ ФАЙЛИ"
},
"CHAT_INTERFACE$DISCONNECTED": {
"en": "Disconnected",
"ja": "切断されました",
@@ -5904,6 +5903,54 @@
"ja": "APIキーを入力",
"uk": "Введіть свій ключ API."
},
"SETTINGS$LLM_API_KEY": {
"en": "LLM API Key",
"zh-CN": "LLM API 密钥",
"zh-TW": "LLM API 金鑰",
"de": "LLM API Schlüssel",
"ko-KR": "LLM API 키",
"no": "LLM API-nøkkel",
"it": "Chiave API LLM",
"pt": "Chave API LLM",
"es": "Clave API LLM",
"ar": "مفتاح API للنماذج اللغوية الكبيرة",
"fr": "Clé API LLM",
"tr": "LLM API Anahtarı",
"ja": "LLM APIキー",
"uk": "Ключ API LLM"
},
"SETTINGS$LLM_API_KEY_DESCRIPTION": {
"en": "You can use this API Key as the LLM API Key for OpenHands open-source and CLI. It will incur cost on your OpenHands Cloud account. Do NOT share this key elsewhere.",
"zh-CN": "您可以将此 API 密钥用作 OpenHands 开源和 CLI 的 LLM API 密钥。它将在您的 OpenHands Cloud 账户上产生费用。请勿在其他地方共享此密钥。",
"zh-TW": "您可以將此 API 金鑰用作 OpenHands 開源和 CLI 的 LLM API 金鑰。它將在您的 OpenHands Cloud 帳戶上產生費用。請勿在其他地方共享此金鑰。",
"de": "Sie können diesen API-Schlüssel als LLM-API-Schlüssel für OpenHands Open-Source und CLI verwenden. Es fallen Kosten für Ihr OpenHands Cloud-Konto an. Teilen Sie diesen Schlüssel NICHT anderswo.",
"ko-KR": "이 API 키를 OpenHands 오픈소스 및 CLI용 LLM API 키로 사용할 수 있습니다. OpenHands Cloud 계정에 비용이 청구됩니다. 이 키를 다른 곳에서 공유하지 마세요.",
"no": "Du kan bruke denne API-nøkkelen som LLM API-nøkkel for OpenHands åpen kildekode og CLI. Det vil påløpe kostnader på din OpenHands Cloud-konto. IKKE del denne nøkkelen andre steder.",
"it": "Puoi utilizzare questa chiave API come chiave API LLM per OpenHands open-source e CLI. Comporterà costi sul tuo account OpenHands Cloud. NON condividere questa chiave altrove.",
"pt": "Você pode usar esta Chave API como a Chave API LLM para OpenHands de código aberto e CLI. Isso incorrerá em custos na sua conta OpenHands Cloud. NÃO compartilhe esta chave em outros lugares.",
"es": "Puede usar esta Clave API como la Clave API LLM para OpenHands de código abierto y CLI. Incurrirá en costos en su cuenta de OpenHands Cloud. NO comparta esta clave en ningún otro lugar.",
"ar": "يمكنك استخدام مفتاح API هذا كمفتاح API للنماذج اللغوية الكبيرة لـ OpenHands مفتوح المصدر وواجهة سطر الأوامر. سيتكبد تكلفة على حساب OpenHands Cloud الخاص بك. لا تشارك هذا المفتاح في أي مكان آخر.",
"fr": "Vous pouvez utiliser cette clé API comme clé API LLM pour OpenHands open-source et CLI. Cela entraînera des coûts sur votre compte OpenHands Cloud. NE partagez PAS cette clé ailleurs.",
"tr": "Bu API Anahtarını, OpenHands açık kaynak ve CLI için LLM API Anahtarı olarak kullanabilirsiniz. OpenHands Cloud hesabınızda maliyet oluşturacaktır. Bu anahtarı başka yerlerde paylaşmayın.",
"ja": "このAPIキーをOpenHandsオープンソースおよびCLIのLLM APIキーとして使用できます。OpenHands Cloudアカウントに費用が発生します。このキーを他の場所で共有しないでください。",
"uk": "Ви можете використовувати цей ключ API як ключ API LLM для OpenHands з відкритим кодом та CLI. Це призведе до витрат на вашому обліковому записі OpenHands Cloud. НЕ діліться цим ключем деінде."
},
"SETTINGS$REFRESH_LLM_API_KEY": {
"en": "Refresh API Key",
"zh-CN": "刷新 API 密钥",
"zh-TW": "刷新 API 金鑰",
"de": "API-Schlüssel aktualisieren",
"ko-KR": "API 키 새로고침",
"no": "Oppdater API-nøkkel",
"it": "Aggiorna chiave API",
"pt": "Atualizar chave API",
"es": "Actualizar clave API",
"ar": "تحديث مفتاح API",
"fr": "Actualiser la clé API",
"tr": "API Anahtarını Yenile",
"ja": "APIキーを更新",
"uk": "Оновити ключ API"
},
"SETTINGS$CONFIRMATION_MODE": {
"en": "Enable Confirmation Mode",
"de": "Bestätigungsmodus aktivieren",
@@ -5968,7 +6015,7 @@
"ja": "セキュリティアナライザー",
"uk": "Увімкнути аналізатор безпеки"
},
"SETTINGS$SECURITY_ANALYZER_PLACEHOLDER":{
"SETTINGS$SECURITY_ANALYZER_PLACEHOLDER": {
"en": "Select a security analyzer…",
"de": "Wählen Sie einen Sicherheitsanalysator aus…",
"zh-CN": "选择一个安全分析器…",
@@ -6096,6 +6143,54 @@
"de": "API-Schlüssel ermöglichen es Ihnen, sich programmatisch bei der OpenHands-API zu authentifizieren. Halten Sie Ihre API-Schlüssel sicher; jeder mit Ihrem API-Schlüssel kann auf Ihr Konto zugreifen. Weitere Informationen zur Verwendung der API finden Sie in unserer <a>API-Dokumentation</a>.",
"uk": "Ключі API дозволяють вам програмно автентифікуватися за допомогою API OpenHands. Зберігайте свої ключі API в безпеці; будь-хто, хто має ваш ключ API, може отримати доступ до вашого облікового запису. Для отримання додаткової інформації про використання API, перегляньте нашу <a>документацію API</a>."
},
"SETTINGS$OPENHANDS_API_KEY_HELP": {
"en": "You can find your OpenHands API Key in the <a>API Keys</a> tab of OpenHands Cloud.",
"ja": "OpenHands APIキーはOpenHands Cloudの<a>APIキー</a>タブで確認できます。",
"zh-CN": "您可以在OpenHands Cloud的<a>API密钥</a>标签页中找到您的OpenHands API密钥。",
"zh-TW": "您可以在OpenHands Cloud的<a>API密鑰</a>標籤頁中找到您的OpenHands API密鑰。",
"ko-KR": "OpenHands API 키는 OpenHands Cloud의 <a>API 키</a> 탭에서 찾을 수 있습니다.",
"no": "Du kan finne din OpenHands API-nøkkel i <a>API-nøkler</a>-fanen i OpenHands Cloud.",
"it": "Puoi trovare la tua chiave API OpenHands nella scheda <a>Chiavi API</a> di OpenHands Cloud.",
"pt": "Você pode encontrar sua chave de API OpenHands na guia <a>Chaves de API</a> do OpenHands Cloud.",
"es": "Puede encontrar su clave API de OpenHands en la pestaña <a>Claves API</a> de OpenHands Cloud.",
"ar": "يمكنك العثور على مفتاح API الخاص بـ OpenHands في علامة التبويب <a>مفاتيح API</a> في OpenHands Cloud.",
"fr": "Vous pouvez trouver votre clé API OpenHands dans l'onglet <a>Clés API</a> d'OpenHands Cloud.",
"tr": "OpenHands API Anahtarınızı OpenHands Cloud'un <a>API Anahtarları</a> sekmesinde bulabilirsiniz.",
"de": "Sie finden Ihren OpenHands API-Schlüssel im Tab <a>API-Schlüssel</a> von OpenHands Cloud.",
"uk": "Ви можете знайти свій ключ API OpenHands у вкладці <a>Ключі API</a> OpenHands Cloud."
},
"SETTINGS$OPENHANDS_API_KEY_HELP_TEXT": {
"en": "You can find your OpenHands API Key in the",
"ja": "OpenHands APIキーは",
"zh-CN": "您可以在",
"zh-TW": "您可以在",
"ko-KR": "OpenHands API 키는",
"no": "Du kan finne din OpenHands API-nøkkel i",
"it": "Puoi trovare la tua chiave API OpenHands nella",
"pt": "Você pode encontrar sua chave de API OpenHands na",
"es": "Puede encontrar su clave API de OpenHands en la",
"ar": "يمكنك العثور على مفتاح API الخاص بـ OpenHands في",
"fr": "Vous pouvez trouver votre clé API OpenHands dans",
"tr": "OpenHands API Anahtarınızı",
"de": "Sie finden Ihren OpenHands API-Schlüssel im",
"uk": "Ви можете знайти свій ключ API OpenHands у"
},
"SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX": {
"en": "tab of OpenHands Cloud.",
"ja": "タブで確認できます。",
"zh-CN": "标签页中找到您的OpenHands API密钥。",
"zh-TW": "標籤頁中找到您的OpenHands API密鑰。",
"ko-KR": "탭에서 찾을 수 있습니다.",
"no": "-fanen i OpenHands Cloud.",
"it": "scheda di OpenHands Cloud.",
"pt": "guia do OpenHands Cloud.",
"es": "pestaña de OpenHands Cloud.",
"ar": "علامة التبويب في OpenHands Cloud.",
"fr": "l'onglet d'OpenHands Cloud.",
"tr": "OpenHands Cloud'un sekmesinde bulabilirsiniz.",
"de": "Tab von OpenHands Cloud.",
"uk": "вкладці OpenHands Cloud."
},
"SETTINGS$CREATE_API_KEY": {
"en": "Create API Key",
"uk": "Створити API ключ",
@@ -6336,6 +6431,22 @@
"es": "Clave API copiada al portapapeles",
"tr": "API anahtarı panoya kopyalandı"
},
"SETTINGS$API_KEY_REFRESHED": {
"en": "API key refreshed successfully",
"uk": "Ключ API успішно оновлено",
"ja": "APIキーが正常に更新されました",
"zh-CN": "API密钥已成功刷新",
"zh-TW": "API金鑰已成功刷新",
"ko-KR": "API 키가 성공적으로 새로고침되었습니다",
"no": "API-nøkkel oppdatert",
"ar": "تم تحديث مفتاح API بنجاح",
"de": "API-Schlüssel erfolgreich aktualisiert",
"fr": "Clé API actualisée avec succès",
"it": "Chiave API aggiornata con successo",
"pt": "Chave API atualizada com sucesso",
"es": "Clave API actualizada con éxito",
"tr": "API anahtarı başarıyla yenilendi"
},
"SETTINGS$API_KEY_NAME_PLACEHOLDER": {
"en": "My API Key",
"uk": "Мій ключ API",
@@ -10640,6 +10751,22 @@
"de": "Git-Anbieter verbinden, um Geheimnisse zu verwalten",
"uk": "Підключити провайдера Git для управління секретами"
},
"SETTINGS$OPENHANDS_API_KEYS": {
"en": "OpenHands API Keys",
"ja": "OpenHands APIキー",
"zh-CN": "OpenHands API密钥",
"zh-TW": "OpenHands API密鑰",
"ko-KR": "OpenHands API 키",
"no": "OpenHands API-nøkler",
"it": "Chiavi API OpenHands",
"pt": "Chaves de API OpenHands",
"es": "Claves API de OpenHands",
"ar": "مفاتيح API لـ OpenHands",
"fr": "Clés API OpenHands",
"tr": "OpenHands API Anahtarları",
"de": "OpenHands API-Schlüssel",
"uk": "API-ключі OpenHands"
},
"CONVERSATION$BUDGET_USAGE_FORMAT": {
"en": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
"ja": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
@@ -10657,52 +10784,52 @@
"uk": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})"
},
"CONVERSATION$CACHE_HIT": {
"en": "Cache Hit:",
"ja": "キャッシュヒット:",
"zh-CN": "缓存命中:",
"zh-TW": "快取命中:",
"ko-KR": "캐시 히트:",
"no": "Cache-treff:",
"it": "Cache Hit:",
"pt": "Cache Hit:",
"es": "Cache Hit:",
"ar": "إصابة التخزين المؤقت:",
"fr": "Cache Hit:",
"tr": "Önbellek İsabeti:",
"de": "Cache-Treffer:",
"uk": "Попадання в кеш:"
"en": "Cache Hit",
"ja": "キャッシュヒット",
"zh-CN": "缓存命中",
"zh-TW": "緩存命中",
"ko-KR": "캐시 히트",
"no": "Cache Treff",
"it": "Cache Hit",
"pt": "Cache Hit",
"es": "Cache Hit",
"ar": "إصابة الذاكرة المؤقتة",
"fr": "Cache Hit",
"tr": "Önbellek İsabeti",
"de": "Cache-Treffer",
"uk": "Кеш-хіт"
},
"CONVERSATION$CACHE_WRITE": {
"en": "Cache Write:",
"ja": "キャッシュ書き込み:",
"zh-CN": "缓存写入:",
"zh-TW": "快取寫入:",
"ko-KR": "캐시 쓰기:",
"no": "Cache-skriving:",
"it": "Cache Write:",
"pt": "Cache Write:",
"es": "Cache Write:",
"ar": "كتابة التخزين المؤقت:",
"fr": "Cache Write:",
"tr": "Önbellek Yazma:",
"de": "Cache-Schreibung:",
"uk": "Запис в кеш:"
"en": "Cache Write",
"ja": "キャッシュ書き込み",
"zh-CN": "缓存写入",
"zh-TW": "緩存寫入",
"ko-KR": "캐시 쓰기",
"no": "Cache Skriv",
"it": "Scrittura Cache",
"pt": "Escrita em Cache",
"es": "Escritura en Caché",
"ar": "كتابة الذاكرة المؤقتة",
"fr": "Écriture Cache",
"tr": "Önbellek Yazma",
"de": "Cache-Schreiben",
"uk": "Запис у кеш"
},
"FEEDBACK$STAR_RATING": {
"en": "",
"ja": "",
"zh-CN": "",
"zh-TW": "",
"ko-KR": "",
"no": "",
"it": "",
"pt": "",
"es": "",
"ar": "",
"fr": "",
"tr": "",
"de": "",
"uk": ""
"en": "Star Rating",
"ja": "星評価",
"zh-CN": "星级评分",
"zh-TW": "星級評分",
"ko-KR": "별점",
"no": "Stjerne Vurdering",
"it": "Valutazione a Stelle",
"pt": "Avaliação por Estrelas",
"es": "Calificación por Estrellas",
"ar": "تقييم النجوم",
"fr": "Évaluation par Étoiles",
"tr": "Yıldız Değerlendirmesi",
"de": "Sternebewertung",
"uk": "Зіркова оцінка"
},
"BUTTON$CONFIRM": {
"en": "Confirm",
@@ -10730,7 +10857,7 @@
"it": "Valore",
"pt": "Valor",
"es": "Valor",
"ar": "القيمة",
"ar": "قيمة",
"fr": "Valeur",
"tr": "Değer",
"de": "Wert",
@@ -10746,42 +10873,58 @@
"it": "Descrizione",
"pt": "Descrição",
"es": "Descripción",
"ar": "الوصف",
"ar": "وصف",
"fr": "Description",
"tr": "Açıklama",
"de": "Beschreibung",
"uk": "Опис"
},
"COMMON$OPTIONAL": {
"en": "(Optional)",
"ja": "(オプション)",
"zh-CN": "(可选)",
"zh-TW": "(可選)",
"ko-KR": "(선택사항)",
"no": "(Valgfritt)",
"it": "(Opzionale)",
"pt": "(Opcional)",
"es": "(Opcional)",
"ar": "(اختياري)",
"fr": "(Optionnel)",
"tr": "(İsteğe bağlı)",
"de": "(Optional)",
"uk": "(Необов'язково)"
"en": "Optional",
"ja": "任意",
"zh-CN": "可选",
"zh-TW": "可選",
"ko-KR": "선택 사항",
"no": "Valgfritt",
"it": "Opzionale",
"pt": "Opcional",
"es": "Opcional",
"ar": "اختياري",
"fr": "Optionnel",
"tr": "İsteğe Bağlı",
"de": "Optional",
"uk": "Необов'язково"
},
"BROWSER$SERVER_MESSAGE": {
"en": "If you tell OpenHands to start a web server, the app will appear here.",
"ja": "OpenHandsにWebサーバーの起動を指示すると、アプリがここに表示されます。",
"zh-CN": "如果您告诉OpenHands启动Web服务器应用程序将在此处显示。",
"zh-TW": "如果您告訴OpenHands啟動Web伺服器應用程式將在此處顯示。",
"ko-KR": "OpenHands에게 웹 서버를 시작하라고 말하면 앱이 여기에 나타납니다.",
"no": "Hvis du ber OpenHands om å starte en webserver, vil appen vises her.",
"it": "Se dici a OpenHands di avviare un server web, l'app apparirà qui.",
"pt": "Se você disser ao OpenHands para iniciar um servidor web, o aplicativo aparecerá aqui.",
"es": "Si le dices a OpenHands que inicie un servidor web, la aplicación aparecerá aquí.",
"ar": "إذا أخبرت OpenHands ببدء خادم ويب، فستظهر التطبيق هنا.",
"fr": "Si vous demandez à OpenHands de démarrer un serveur web, l'application apparaîtra ici.",
"tr": "OpenHands'e bir web sunucusu başlatmasını söylerseniz, uygulama burada görünecektir.",
"de": "Wenn Sie OpenHands anweisen, einen Webserver zu starten, wird die App hier angezeigt.",
"uk": "Якщо ви скажете OpenHands запустити веб-сервер, додаток з'явиться тут."
"en": "Server Message",
"ja": "サーバーメッセージ",
"zh-CN": "服务器消息",
"zh-TW": "伺服器訊息",
"ko-KR": "서버 메시지",
"no": "Servermelding",
"it": "Messaggio del Server",
"pt": "Mensagem do Servidor",
"es": "Mensaje del Servidor",
"ar": "رسالة الخادم",
"fr": "Message du Serveur",
"tr": "Sunucu Mesajı",
"de": "Server-Nachricht",
"uk": "Повідомлення сервера"
},
"API$NO_KEY_AVAILABLE": {
"en": "No API key available",
"ja": "APIキーが利用できません",
"zh-CN": "没有可用的API密钥",
"zh-TW": "沒有可用的API密鑰",
"ko-KR": "사용 가능한 API 키 없음",
"no": "Ingen API-nøkkel tilgjengelig",
"it": "Nessuna chiave API disponibile",
"pt": "Nenhuma chave API disponível",
"es": "No hay clave API disponible",
"ar": "لا يوجد مفتاح API متاح",
"fr": "Aucune clé API disponible",
"tr": "Kullanılabilir API anahtarı yok",
"de": "Kein API-Schlüssel verfügbar",
"uk": "Немає доступного API-ключа"
}
}

View File

@@ -24,6 +24,7 @@ import { LlmSettingsInputsSkeleton } from "#/components/features/settings/llm-se
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { getProviderId } from "#/utils/map-provider";
import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models";
function LlmSettingsScreen() {
const { t } = useTranslation();
@@ -49,6 +50,11 @@ function LlmSettingsScreen() {
securityAnalyzer: false,
});
// Track the currently selected model to show help text
const [currentSelectedModel, setCurrentSelectedModel] = React.useState<
string | null
>(null);
const modelsAndProviders = organizeModelsAndProviders(
resources?.models || [],
);
@@ -74,6 +80,13 @@ function LlmSettingsScreen() {
else setView("basic");
}, [settings, resources]);
// Initialize currentSelectedModel with the current settings
React.useEffect(() => {
if (settings?.LLM_MODEL) {
setCurrentSelectedModel(settings.LLM_MODEL);
}
}, [settings?.LLM_MODEL]);
const handleSuccessfulMutation = () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED_WARNING));
setDirtyInputs({
@@ -184,6 +197,9 @@ function LlmSettingsScreen() {
...prev,
model: modelIsDirty,
}));
// Track the currently selected model for help text display
setCurrentSelectedModel(model);
};
const handleApiKeyIsDirty = (apiKey: string) => {
@@ -208,6 +224,9 @@ function LlmSettingsScreen() {
...prev,
model: modelIsDirty,
}));
// Track the currently selected model for help text display
setCurrentSelectedModel(model);
};
const handleBaseUrlIsDirty = (baseUrl: string) => {
@@ -279,13 +298,23 @@ function LlmSettingsScreen() {
className="flex flex-col gap-6"
>
{!isLoading && !isFetching && (
<ModelSelector
models={modelsAndProviders}
currentModel={
settings.LLM_MODEL || "anthropic/claude-sonnet-4-20250514"
}
onChange={handleModelIsDirty}
/>
<>
<ModelSelector
models={modelsAndProviders}
currentModel={settings.LLM_MODEL || DEFAULT_OPENHANDS_MODEL}
onChange={handleModelIsDirty}
/>
{(settings.LLM_MODEL?.startsWith("openhands/") ||
currentSelectedModel?.startsWith("openhands/")) && (
<HelpLink
testId="openhands-api-key-help"
text={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_TEXT)}
linkText={t(I18nKey.SETTINGS$NAV_API_KEYS)}
href="https://app.all-hands.dev/settings/api-keys"
suffix={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX)}
/>
)}
</>
)}
<SettingsInput
@@ -344,14 +373,22 @@ function LlmSettingsScreen() {
testId="llm-custom-model-input"
name="llm-custom-model-input"
label={t(I18nKey.SETTINGS$CUSTOM_MODEL)}
defaultValue={
settings.LLM_MODEL || "anthropic/claude-sonnet-4-20250514"
}
placeholder="anthropic/claude-sonnet-4-20250514"
defaultValue={settings.LLM_MODEL || DEFAULT_OPENHANDS_MODEL}
placeholder={DEFAULT_OPENHANDS_MODEL}
type="text"
className="w-full max-w-[680px]"
onChange={handleCustomModelIsDirty}
/>
{(settings.LLM_MODEL?.startsWith("openhands/") ||
currentSelectedModel?.startsWith("openhands/")) && (
<HelpLink
testId="openhands-api-key-help-2"
text={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_TEXT)}
linkText={t(I18nKey.SETTINGS$NAV_API_KEYS)}
href="https://app.all-hands.dev/settings/api-keys"
suffix={t(I18nKey.SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX)}
/>
)}
<SettingsInput
testId="base-url-input"

View File

@@ -1,7 +1,9 @@
import { isNumber } from "./is-number";
import {
VERIFIED_ANTHROPIC_MODELS,
VERIFIED_MISTRAL_MODELS,
VERIFIED_OPENAI_MODELS,
VERIFIED_OPENHANDS_MODELS,
} from "./verified-models";
/**
@@ -47,6 +49,12 @@ export const extractModelAndProvider = (model: string) => {
if (VERIFIED_ANTHROPIC_MODELS.includes(split[0])) {
return { provider: "anthropic", model: split[0], separator: "/" };
}
if (VERIFIED_MISTRAL_MODELS.includes(split[0])) {
return { provider: "mistral", model: split[0], separator: "/" };
}
if (VERIFIED_OPENHANDS_MODELS.includes(split[0])) {
return { provider: "openhands", model: split[0], separator: "/" };
}
// return as model only
return { provider: "", model, separator: "" };
}

View File

@@ -23,6 +23,7 @@ export const MAP_PROVIDER = {
replicate: "Replicate",
voyage: "Voyage AI",
openrouter: "OpenRouter",
openhands: "OpenHands",
};
export const mapProvider = (provider: string) =>

View File

@@ -1,5 +1,10 @@
// Here are the list of verified models and providers that we know work well with OpenHands.
export const VERIFIED_PROVIDERS = ["openai", "azure", "anthropic", "deepseek"];
export const VERIFIED_PROVIDERS = [
"openhands",
"anthropic",
"openai",
"mistral",
];
export const VERIFIED_MODELS = [
"o3-mini-2025-01-31",
"o3-2025-04-16",
@@ -8,7 +13,12 @@ export const VERIFIED_MODELS = [
"claude-3-7-sonnet-20250219",
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"gemini-2.5-pro",
"o4-mini",
"deepseek-chat",
"devstral-small-2505",
"devstral-small-2507",
"devstral-medium-2507",
];
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
@@ -16,10 +26,8 @@ export const VERIFIED_MODELS = [
export const VERIFIED_OPENAI_MODELS = [
"gpt-4o",
"gpt-4o-mini",
"gpt-4-32k",
"gpt-4.1",
"gpt-4.1-2025-04-14",
"o1-mini",
"o3",
"o3-2025-04-16",
"o4-mini",
@@ -30,15 +38,33 @@ export const VERIFIED_OPENAI_MODELS = [
// LiteLLM does not return the compatible Anthropic models with the provider, so we list them here to set them ourselves
// (e.g., they return `claude-3-5-sonnet-20241022` instead of `anthropic/claude-3-5-sonnet-20241022`)
export const VERIFIED_ANTHROPIC_MODELS = [
"claude-2",
"claude-2.1",
"claude-3-5-sonnet-20240620",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-haiku-20240307",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-7-sonnet-20250219",
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
];
// LiteLLM does not return the compatible Mistral models with the provider, so we list them here to set them ourselves
// (e.g., they return `devstral-small-2505` instead of `mistral/devstral-small-2505`)
export const VERIFIED_MISTRAL_MODELS = [
"devstral-small-2505",
"devstral-small-2507",
"devstral-medium-2507",
];
// LiteLLM does not return the compatible OpenHands models with the provider, so we list them here to set them ourselves
// (e.g., they return `claude-sonnet-4-20250514` instead of `openhands/claude-sonnet-4-20250514`)
export const VERIFIED_OPENHANDS_MODELS = [
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"gemini-2.5-pro",
"o4-mini",
"devstral-small-2505",
"devstral-small-2507",
"devstral-medium-2507",
];
// Default model for OpenHands provider
export const DEFAULT_OPENHANDS_MODEL = "openhands/claude-sonnet-4-20250514";

View File

@@ -17,6 +17,7 @@ from openhands.cli.utils import (
VERIFIED_ANTHROPIC_MODELS,
VERIFIED_MISTRAL_MODELS,
VERIFIED_OPENAI_MODELS,
VERIFIED_OPENHANDS_MODELS,
VERIFIED_PROVIDERS,
organize_models_and_providers,
)
@@ -240,6 +241,11 @@ async def modify_llm_settings_basic(
m for m in provider_models if m not in VERIFIED_MISTRAL_MODELS
]
provider_models = VERIFIED_MISTRAL_MODELS + provider_models
if provider == 'openhands':
provider_models = [
m for m in provider_models if m not in VERIFIED_OPENHANDS_MODELS
]
provider_models = VERIFIED_OPENHANDS_MODELS + provider_models
# Set default model to the best verified model for the provider
if provider == 'anthropic' and VERIFIED_ANTHROPIC_MODELS:
@@ -251,54 +257,81 @@ async def modify_llm_settings_basic(
elif provider == 'mistral' and VERIFIED_MISTRAL_MODELS:
# Use the first model in the VERIFIED_MISTRAL_MODELS list as it's the best/newest
default_model = VERIFIED_MISTRAL_MODELS[0]
elif provider == 'openhands' and VERIFIED_OPENHANDS_MODELS:
# Use the first model in the VERIFIED_OPENHANDS_MODELS list as it's the best/newest
default_model = VERIFIED_OPENHANDS_MODELS[0]
else:
# For other providers, use the first model in the list
default_model = (
provider_models[0] if provider_models else 'claude-sonnet-4-20250514'
)
# Show the default model but allow changing it
print_formatted_text(
HTML(f'\n<grey>Default model: </grey><green>{default_model}</green>')
)
change_model = (
cli_confirm(
# For OpenHands provider, directly show all verified models without the "use default" option
if provider == 'openhands':
print_formatted_text(HTML('\n<grey>Available OpenHands models:</grey>'))
# Create a list of models for the cli_confirm function
model_choices = VERIFIED_OPENHANDS_MODELS
model_choice = cli_confirm(
config,
'Do you want to use a different model?',
[f'Use {default_model}', 'Select another model'],
'(Step 2/3) Select LLM Model:',
model_choices,
)
== 1
)
if change_model:
model_completer = FuzzyWordCompleter(provider_models)
# Get the selected model from the list
model = model_choices[model_choice]
# Define a validator function that allows custom models but shows a warning
def model_validator(x):
# Allow any non-empty model name
if not x.strip():
return False
# Show a warning for models not in the predefined list, but still allow them
if x not in provider_models:
print_formatted_text(
HTML(
f'<yellow>Warning: {x} is not in the predefined list for provider {provider}. '
f'Make sure this model name is correct.</yellow>'
)
)
return True
model = await get_validated_input(
session,
'(Step 2/3) Select LLM Model (TAB for options, CTRL-c to cancel): ',
completer=model_completer,
validator=model_validator,
error_message='Model name cannot be empty',
)
else:
# Use the default model
model = default_model
# For other providers, show the default model but allow changing it
print_formatted_text(
HTML(f'\n<grey>Default model: </grey><green>{default_model}</green>')
)
change_model = (
cli_confirm(
config,
'Do you want to use a different model?',
[f'Use {default_model}', 'Select another model'],
)
== 1
)
if change_model:
model_completer = FuzzyWordCompleter(provider_models)
# Define a validator function that allows custom models but shows a warning
def model_validator(x):
# Allow any non-empty model name
if not x.strip():
return False
# Show a warning for models not in the predefined list, but still allow them
if x not in provider_models:
print_formatted_text(
HTML(
f'<yellow>Warning: {x} is not in the predefined list for provider {provider}. '
f'Make sure this model name is correct.</yellow>'
)
)
return True
model = await get_validated_input(
session,
'(Step 2/3) Select LLM Model (TAB for options, CTRL-c to cancel): ',
completer=model_completer,
validator=model_validator,
error_message='Model name cannot be empty',
)
else:
# Use the default model
model = default_model
if provider == 'openhands':
print_formatted_text(
HTML(
'\nYou can find your OpenHands LLM API Key in the <a href="https://app.all-hands.dev/settings/api-keys">API Keys</a> tab of OpenHands Cloud: https://app.all-hands.dev/settings/api-keys'
)
)
api_key = await get_validated_input(
session,

View File

@@ -104,6 +104,8 @@ def extract_model_and_provider(model: str) -> ModelInfo:
return ModelInfo(provider='anthropic', model=split[0], separator='/')
if split[0] in VERIFIED_MISTRAL_MODELS:
return ModelInfo(provider='mistral', model=split[0], separator='/')
if split[0] in VERIFIED_OPENHANDS_MODELS:
return ModelInfo(provider='openhands', model=split[0], separator='/')
# return as model only
return ModelInfo(provider='', model=model, separator='')
@@ -145,7 +147,7 @@ def organize_models_and_providers(
return result_dict
VERIFIED_PROVIDERS = ['anthropic', 'openai', 'mistral']
VERIFIED_PROVIDERS = ['openhands', 'anthropic', 'openai', 'mistral']
VERIFIED_OPENAI_MODELS = [
'o4-mini',
@@ -175,6 +177,17 @@ VERIFIED_ANTHROPIC_MODELS = [
VERIFIED_MISTRAL_MODELS = [
'devstral-small-2505',
'devstral-small-2507',
'devstral-medium-2507',
]
VERIFIED_OPENHANDS_MODELS = [
'claude-opus-4-20250514',
'devstral-small-2507',
'devstral-medium-2507',
'o4-mini',
'claude-sonnet-4-20250514',
'gemini-2.5-pro',
]

View File

@@ -173,6 +173,15 @@ class LLM(RetryMixin, DebugMixin):
# litellm will handle it a bit differently than the openai-compatible params
kwargs['top_k'] = self.config.top_k
# Handle OpenHands provider - rewrite to litellm_proxy
if self.config.model.startswith('openhands/'):
model_name = self.config.model.removeprefix('openhands/')
self.config.model = f'litellm_proxy/{model_name}'
self.config.base_url = 'https://llm-proxy.app.all-hands.dev/'
logger.debug(
f'Rewrote openhands/{model_name} to {self.config.model} with base URL {self.config.base_url}'
)
if (
self.config.model.lower() in REASONING_EFFORT_SUPPORTED_MODELS
or self.config.model.split('/')[-1] in REASONING_EFFORT_SUPPORTED_MODELS

View File

@@ -54,7 +54,8 @@ def refresh_files() -> list[str]:
@app.get('/api/options/config')
def get_config() -> dict[str, str]:
return {'APP_MODE': 'oss'}
# return {'APP_MODE': 'oss'}
return {'APP_MODE': 'saas'}
@app.get('/api/options/security-analyzers')

View File

@@ -53,4 +53,16 @@ def get_supported_llm_models(config: OpenHandsConfig) -> list[str]:
except httpx.HTTPError as e:
logger.error(f'Error getting OLLAMA models: {e}')
# Add OpenHands provider models
openhands_models = [
'openhands/claude-sonnet-4-20250514',
'openhands/claude-opus-4-20250514',
'openhands/gemini-2.5-pro',
'openhands/o4-mini',
'openhands/devstral-small-2505',
'openhands/devstral-small-2507',
'openhands/devstral-medium-2507',
]
model_list = openhands_models + model_list
return list(sorted(set(model_list)))