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 @@ + + + + + + + diff --git a/runtime/bundles/org.eclipse.core.llm/.project b/runtime/bundles/org.eclipse.core.llm/.project new file mode 100644 index 00000000000..9aa2ee6026b --- /dev/null +++ b/runtime/bundles/org.eclipse.core.llm/.project @@ -0,0 +1,28 @@ + + + org.eclipse.core.llm + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.pde.ManifestBuilder + + + + + org.eclipse.pde.SchemaBuilder + + + + + + org.eclipse.pde.PluginNature + org.eclipse.jdt.core.javanature + + diff --git a/runtime/bundles/org.eclipse.core.llm/META-INF/MANIFEST.MF b/runtime/bundles/org.eclipse.core.llm/META-INF/MANIFEST.MF new file mode 100644 index 00000000000..ff228bc81be --- /dev/null +++ b/runtime/bundles/org.eclipse.core.llm/META-INF/MANIFEST.MF @@ -0,0 +1,11 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: Core LLM +Bundle-SymbolicName: org.eclipse.core.llm;singleton:=true +Bundle-Version: 1.0.0.qualifier +Bundle-Vendor: Eclipse.org +Export-Package: org.eclipse.core.llm +Require-Bundle: org.eclipse.swt, + org.eclipse.jface +Bundle-RequiredExecutionEnvironment: JavaSE-21 +Automatic-Module-Name: org.eclipse.core.llm diff --git a/runtime/bundles/org.eclipse.core.llm/about.html b/runtime/bundles/org.eclipse.core.llm/about.html new file mode 100644 index 00000000000..a1c7fa411c6 --- /dev/null +++ b/runtime/bundles/org.eclipse.core.llm/about.html @@ -0,0 +1,15 @@ + + + + +About + + +

About This Content

+

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(); + HttpResponse response = HttpClient.newHttpClient() + .send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() / 100 != 2) { + throw new IOException("LLM request failed: HTTP " + response.statusCode() + " - " + response.body()); //$NON-NLS-1$ //$NON-NLS-2$ + } + return response.body(); + } + + private static String escape(String s) { + StringBuilder sb = new StringBuilder(s.length() + 8); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"' -> sb.append("\\\""); //$NON-NLS-1$ + case '\\' -> sb.append("\\\\"); //$NON-NLS-1$ + case '\n' -> sb.append("\\n"); //$NON-NLS-1$ + case '\r' -> sb.append("\\r"); //$NON-NLS-1$ + case '\t' -> sb.append("\\t"); //$NON-NLS-1$ + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); //$NON-NLS-1$ + } else { + sb.append(c); + } + } + } + } + return sb.toString(); + } +} diff --git a/runtime/tests/org.eclipse.core.llm.tests/.classpath b/runtime/tests/org.eclipse.core.llm.tests/.classpath new file mode 100644 index 00000000000..e54fbc9309e --- /dev/null +++ b/runtime/tests/org.eclipse.core.llm.tests/.classpath @@ -0,0 +1,7 @@ + + + + + + + diff --git a/runtime/tests/org.eclipse.core.llm.tests/.project b/runtime/tests/org.eclipse.core.llm.tests/.project new file mode 100644 index 00000000000..420f75c4a5c --- /dev/null +++ b/runtime/tests/org.eclipse.core.llm.tests/.project @@ -0,0 +1,28 @@ + + + org.eclipse.core.llm.tests + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.pde.ManifestBuilder + + + + + org.eclipse.pde.SchemaBuilder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.pde.PluginNature + + diff --git a/runtime/tests/org.eclipse.core.llm.tests/META-INF/MANIFEST.MF b/runtime/tests/org.eclipse.core.llm.tests/META-INF/MANIFEST.MF new file mode 100644 index 00000000000..ced22931bb7 --- /dev/null +++ b/runtime/tests/org.eclipse.core.llm.tests/META-INF/MANIFEST.MF @@ -0,0 +1,14 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: Core LLM Tests +Bundle-SymbolicName: org.eclipse.core.llm.tests;singleton:=true +Bundle-Version: 1.0.0.qualifier +Bundle-Vendor: Eclipse.org +Require-Bundle: org.eclipse.core.llm, + org.eclipse.swt, + org.eclipse.jface +Import-Package: com.sun.net.httpserver, + org.junit.jupiter.api;version="[5.14.0,6.0.0)" +Bundle-RequiredExecutionEnvironment: JavaSE-21 +Bundle-ActivationPolicy: lazy +Automatic-Module-Name: org.eclipse.core.llm.tests diff --git a/runtime/tests/org.eclipse.core.llm.tests/about.html b/runtime/tests/org.eclipse.core.llm.tests/about.html new file mode 100644 index 00000000000..da7ee7ab9e1 --- /dev/null +++ b/runtime/tests/org.eclipse.core.llm.tests/about.html @@ -0,0 +1,8 @@ + + +About + +

About This Content

+

The 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 captured = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); //$NON-NLS-1$ + server.createContext("/", ex -> { //$NON-NLS-1$ + captured.set(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] body = "ok".getBytes(StandardCharsets.UTF_8); //$NON-NLS-1$ + ex.sendResponseHeaders(200, body.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(body); + } + }); + server.start(); + try { + String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/"; //$NON-NLS-1$ //$NON-NLS-2$ + LlmModel m = new LlmModel(url, "test-model"); //$NON-NLS-1$ + String reply = m.infer("Hello \"world\"\nline2\ttabbed"); //$NON-NLS-1$ + assertEquals("ok", reply); //$NON-NLS-1$ + String sent = captured.get(); + assertTrue(sent.contains("\"model\":\"test-model\""), sent); //$NON-NLS-1$ + assertTrue(sent.contains("\"prompt\":\"Hello \\\"world\\\"\\nline2\\ttabbed\""), sent); //$NON-NLS-1$ + assertTrue(sent.contains("\"stream\":false"), sent); //$NON-NLS-1$ + } finally { + server.stop(0); + } + } + + @Test + void inferFailsOnNon2xx() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); //$NON-NLS-1$ + server.createContext("/", ex -> { //$NON-NLS-1$ + byte[] body = "boom".getBytes(StandardCharsets.UTF_8); //$NON-NLS-1$ + ex.sendResponseHeaders(500, body.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(body); + } + }); + server.start(); + try { + LlmModel m = new LlmModel("http://127.0.0.1:" + server.getAddress().getPort() + "/", "x"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + assertThrows(IOException.class, () -> m.infer("q")); //$NON-NLS-1$ + } finally { + server.stop(0); + } + } + + @Test + void inferOnUiThreadThrows() throws Exception { + Display display = Display.getDefault(); + AtomicReference caught = new AtomicReference<>(); + display.syncExec(() -> { + try { + new LlmModel("http://example/", "m").infer("q"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ + } catch (Throwable t) { + caught.set(t); + } + }); + assertTrue(caught.get() instanceof IllegalStateException, + "expected IllegalStateException, got " + caught.get()); //$NON-NLS-1$ + } +}