diff --git a/runtime/bundles/org.eclipse.core.llm/.classpath b/runtime/bundles/org.eclipse.core.llm/.classpath
new file mode 100644
index 00000000000..375961e4d61
--- /dev/null
+++ b/runtime/bundles/org.eclipse.core.llm/.classpath
@@ -0,0 +1,7 @@
+
+
The Eclipse Foundation makes available all content in this plug-in +("Content"). Unless otherwise indicated below, the Content is provided +to you under the terms and conditions of the Eclipse Public License +Version 2.0 ("EPL"). A copy of the EPL is available at +https://www.eclipse.org/legal/epl-2.0/.
+ + diff --git a/runtime/bundles/org.eclipse.core.llm/build.properties b/runtime/bundles/org.eclipse.core.llm/build.properties new file mode 100644 index 00000000000..9cbab3c135e --- /dev/null +++ b/runtime/bundles/org.eclipse.core.llm/build.properties @@ -0,0 +1,6 @@ +source.. = src/ +output.. = bin/ +bin.includes = META-INF/,\ + .,\ + about.html +src.includes = about.html diff --git a/runtime/bundles/org.eclipse.core.llm/src/org/eclipse/core/llm/LlmConfigurationDialog.java b/runtime/bundles/org.eclipse.core.llm/src/org/eclipse/core/llm/LlmConfigurationDialog.java new file mode 100644 index 00000000000..2c3f22ec6ae --- /dev/null +++ b/runtime/bundles/org.eclipse.core.llm/src/org/eclipse/core/llm/LlmConfigurationDialog.java @@ -0,0 +1,82 @@ +/******************************************************************************* + * Copyright (c) 2026 Ericsson + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ +package org.eclipse.core.llm; + +import org.eclipse.jface.dialogs.Dialog; +import org.eclipse.jface.dialogs.IDialogConstants; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +/** + * Simple JFace dialog to configure the URL and model name of a {@link LlmModel}. + * Retrieve the result with {@link #getModel()} after {@link #open()} returns + * {@link org.eclipse.jface.window.Window#OK}. + */ +public class LlmConfigurationDialog extends Dialog { + + private String url; + private String model; + private Text urlText; + private Text modelText; + private LlmModel result; + + public LlmConfigurationDialog(Shell parent, LlmModel initial) { + super(parent); + this.url = initial != null ? initial.url() : ""; //$NON-NLS-1$ + this.model = initial != null ? initial.model() : ""; //$NON-NLS-1$ + } + + public LlmModel getModel() { + return result; + } + + @Override + protected void configureShell(Shell shell) { + super.configureShell(shell); + shell.setText("LLM Configuration"); //$NON-NLS-1$ + } + + @Override + protected Control createDialogArea(Composite parent) { + Composite area = (Composite) super.createDialogArea(parent); + Composite c = new Composite(area, SWT.NONE); + c.setLayout(new GridLayout(2, false)); + c.setLayoutData(new GridData(GridData.FILL_BOTH)); + + new Label(c, SWT.NONE).setText("URL:"); //$NON-NLS-1$ + urlText = new Text(c, SWT.BORDER); + urlText.setText(url); + GridData gd = new GridData(GridData.FILL_HORIZONTAL); + gd.widthHint = 320; + urlText.setLayoutData(gd); + + new Label(c, SWT.NONE).setText("Model:"); //$NON-NLS-1$ + modelText = new Text(c, SWT.BORDER); + modelText.setText(model); + modelText.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); + + return area; + } + + @Override + protected void buttonPressed(int buttonId) { + if (buttonId == IDialogConstants.OK_ID) { + result = new LlmModel(urlText.getText().trim(), modelText.getText().trim()); + } + super.buttonPressed(buttonId); + } +} diff --git a/runtime/bundles/org.eclipse.core.llm/src/org/eclipse/core/llm/LlmModel.java b/runtime/bundles/org.eclipse.core.llm/src/org/eclipse/core/llm/LlmModel.java new file mode 100644 index 00000000000..c88f9bd70f9 --- /dev/null +++ b/runtime/bundles/org.eclipse.core.llm/src/org/eclipse/core/llm/LlmModel.java @@ -0,0 +1,93 @@ +/******************************************************************************* + * Copyright (c) 2026 Ericsson + * + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ +package org.eclipse.core.llm; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +import org.eclipse.swt.widgets.Display; + +/** + * Minimal LLM client. An instance is configured with the endpoint URL and the + * model name and exposes {@link #infer(String)} to query the model. + *+ * {@link #infer(String)} performs a blocking HTTP request and must never be + * invoked from the SWT UI thread; doing so throws {@link IllegalStateException}. + *
+ */ +public final class LlmModel { + + private final String url; + private final String model; + + public LlmModel(String url, String model) { + this.url = url; + this.model = model; + } + + public String url() { + return url; + } + + public String model() { + return model; + } + + /** + * Send {@code prompt} to the configured LLM and return the raw response body. + * + * @throws IllegalStateException if invoked from the SWT UI thread + * @throws IOException if the HTTP call fails + */ + public String infer(String prompt) throws IOException, InterruptedException { + if (Display.getCurrent() != null) { + throw new IllegalStateException("LlmModel.infer must not be called from the UI thread"); //$NON-NLS-1$ + } + String body = "{\"model\":\"" + escape(model) + "\",\"prompt\":\"" + escape(prompt) + "\",\"stream\":false}"; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofMinutes(2)) + .header("Content-Type", "application/json") //$NON-NLS-1$ //$NON-NLS-2$ + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponseThe Eclipse Foundation makes available all content in this plug-in ("Content") under the Eclipse Public License Version 2.0 +(https://www.eclipse.org/legal/epl-2.0/).
+ diff --git a/runtime/tests/org.eclipse.core.llm.tests/build.properties b/runtime/tests/org.eclipse.core.llm.tests/build.properties new file mode 100644 index 00000000000..83d747a1e16 --- /dev/null +++ b/runtime/tests/org.eclipse.core.llm.tests/build.properties @@ -0,0 +1,6 @@ +source.. = src/ +output.. = bin/ +bin.includes = .,\ + META-INF/,\ + about.html +src.includes = about.html diff --git a/runtime/tests/org.eclipse.core.llm.tests/src/org/eclipse/core/llm/tests/LlmConfigurationDialogTest.java b/runtime/tests/org.eclipse.core.llm.tests/src/org/eclipse/core/llm/tests/LlmConfigurationDialogTest.java new file mode 100644 index 00000000000..df096fac860 --- /dev/null +++ b/runtime/tests/org.eclipse.core.llm.tests/src/org/eclipse/core/llm/tests/LlmConfigurationDialogTest.java @@ -0,0 +1,99 @@ +/******************************************************************************* + * Copyright (c) 2026 Ericsson + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ +package org.eclipse.core.llm.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.lang.reflect.Field; + +import org.eclipse.core.llm.LlmConfigurationDialog; +import org.eclipse.core.llm.LlmModel; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; +import org.junit.jupiter.api.Test; + +public class LlmConfigurationDialogTest { + + @Test + void okBuildsConfiguredModel() throws Exception { + runOnUi(display -> { + Shell parent = new Shell(display); + try { + LlmConfigurationDialog dlg = new LlmConfigurationDialog(parent, + new LlmModel("http://orig/", "orig-model")); //$NON-NLS-1$ //$NON-NLS-2$ + dlg.setBlockOnOpen(false); + dlg.open(); + setText(dlg, "urlText", "http://new/"); //$NON-NLS-1$ //$NON-NLS-2$ + setText(dlg, "modelText", "new-model"); //$NON-NLS-1$ //$NON-NLS-2$ + pressButton(dlg, org.eclipse.jface.dialogs.IDialogConstants.OK_ID); + LlmModel m = dlg.getModel(); + assertEquals("http://new/", m.url()); //$NON-NLS-1$ + assertEquals("new-model", m.model()); //$NON-NLS-1$ + } finally { + parent.dispose(); + } + }); + } + + @Test + void cancelYieldsNoModel() throws Exception { + runOnUi(display -> { + Shell parent = new Shell(display); + try { + LlmConfigurationDialog dlg = new LlmConfigurationDialog(parent, null); + dlg.setBlockOnOpen(false); + dlg.open(); + dlg.close(); + assertNull(dlg.getModel()); + assertEquals(Window.CANCEL, dlg.getReturnCode()); + } finally { + parent.dispose(); + } + }); + } + + private static void setText(Object target, String fieldName, String value) throws Exception { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + ((Text) f.get(target)).setText(value); + } + + private static void pressButton(Object dialog, int buttonId) throws Exception { + java.lang.reflect.Method m = org.eclipse.jface.dialogs.Dialog.class + .getDeclaredMethod("buttonPressed", int.class); //$NON-NLS-1$ + m.setAccessible(true); + m.invoke(dialog, buttonId); + } + + private interface UiRunnable { + void run(Display display) throws Exception; + } + + private static void runOnUi(UiRunnable r) throws Exception { + Display display = Display.getDefault(); + Throwable[] err = new Throwable[1]; + display.syncExec(() -> { + try { + r.run(display); + } catch (Throwable t) { + err[0] = t; + } + }); + if (err[0] instanceof Exception e) { + throw e; + } + if (err[0] != null) { + throw new RuntimeException(err[0]); + } + } +} diff --git a/runtime/tests/org.eclipse.core.llm.tests/src/org/eclipse/core/llm/tests/LlmModelTest.java b/runtime/tests/org.eclipse.core.llm.tests/src/org/eclipse/core/llm/tests/LlmModelTest.java new file mode 100644 index 00000000000..04c57a8b354 --- /dev/null +++ b/runtime/tests/org.eclipse.core.llm.tests/src/org/eclipse/core/llm/tests/LlmModelTest.java @@ -0,0 +1,97 @@ +/******************************************************************************* + * Copyright (c) 2026 Ericsson + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which accompanies this distribution, + * and is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + *******************************************************************************/ +package org.eclipse.core.llm.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.core.llm.LlmModel; +import org.eclipse.swt.widgets.Display; +import org.junit.jupiter.api.Test; + +import com.sun.net.httpserver.HttpServer; + +public class LlmModelTest { + + @Test + void constructorAndGetters() { + LlmModel m = new LlmModel("http://example/api", "my-model"); //$NON-NLS-1$ //$NON-NLS-2$ + assertEquals("http://example/api", m.url()); //$NON-NLS-1$ + assertEquals("my-model", m.model()); //$NON-NLS-1$ + } + + @Test + void inferSendsExpectedJsonAndReturnsBody() throws Exception { + AtomicReference