001/* 002 * Copyright (c) 2026 Singular 003 * SPDX-License-Identifier: MIT 004 */ 005package ai.singlr.runtime; 006 007import ai.singlr.core.common.Strings; 008import ai.singlr.session.SessionOptions; 009import io.helidon.webserver.WebServer; 010import io.helidon.webserver.http.HttpRouting; 011import java.util.Objects; 012import java.util.function.Function; 013import tools.jackson.databind.ObjectMapper; 014import tools.jackson.databind.json.JsonMapper; 015 016/** 017 * Programmatic Helidon SE {@link WebServer} wrapping an {@link AgentHttpService}. Designed for 018 * embedding in a service main, or in tests that want a real HTTP endpoint on a random port. 019 * 020 * <p>Typical use: 021 * 022 * <pre>{@code 023 * try (var server = RuntimeServer.builder() 024 * .withRegistry(SessionRegistry.inMemory()) 025 * .withOptionsFactory(sessionId -> SessionOptions.newBuilder() 026 * .withModel(myModel).withSessionId(sessionId).build()) 027 * .withPort(0) // random 028 * .build()) { 029 * var port = server.port(); 030 * // … drive over HTTP … 031 * } 032 * }</pre> 033 * 034 * <h2>Thread-safety</h2> 035 * 036 * The wrapped {@link WebServer} is thread-safe. {@link #close()} is idempotent. 037 */ 038public final class RuntimeServer implements AutoCloseable { 039 040 private final WebServer webServer; 041 private final SessionRegistry registry; 042 043 private RuntimeServer(WebServer webServer, SessionRegistry registry) { 044 this.webServer = webServer; 045 this.registry = registry; 046 } 047 048 /** 049 * Start building a server. 050 * 051 * @return a fresh builder 052 */ 053 public static Builder builder() { 054 return new Builder(); 055 } 056 057 /** 058 * The port the server is bound to. Useful when constructed with port 0 (auto-assigned). 059 * 060 * @return the bound port 061 */ 062 public int port() { 063 return webServer.port(); 064 } 065 066 /** 067 * The registry of sessions the server is fronting. 068 * 069 * @return non-null registry 070 */ 071 public SessionRegistry registry() { 072 return registry; 073 } 074 075 /** Stop the server and close every registered session. Idempotent. */ 076 @Override 077 public void close() { 078 webServer.stop(); 079 registry.closeAll(); 080 } 081 082 /** Mutable builder for {@link RuntimeServer}. */ 083 public static final class Builder { 084 085 /** 086 * Fail-secure default: loopback only. The {@code POST /sessions} route is unauthenticated and 087 * creates real sessions on the configured model; binding all interfaces by default would expose 088 * that surface to anything that can route to the host. Deployers who genuinely want external 089 * traffic must opt in via {@link #withHost(String)} with {@code "0.0.0.0"} (or a specific 090 * external interface). 091 */ 092 static final String DEFAULT_HOST = "127.0.0.1"; 093 094 /** Visible for tests so the default value can be asserted without reflection. */ 095 static String defaultHostForTests() { 096 return DEFAULT_HOST; 097 } 098 099 private SessionRegistry registry; 100 private Function<String, SessionOptions> optionsFactory; 101 private ObjectMapper objectMapper; 102 private String routePrefix = "/v1"; 103 private int port = 0; 104 private String host = DEFAULT_HOST; 105 106 private Builder() {} 107 108 /** 109 * Bind the registry the service exposes. 110 * 111 * @param registry non-null registry 112 * @return this builder 113 * @throws NullPointerException if {@code registry} is null 114 */ 115 public Builder withRegistry(SessionRegistry registry) { 116 this.registry = Objects.requireNonNull(registry, "registry must not be null"); 117 return this; 118 } 119 120 /** 121 * Bind the options factory the service calls for every {@code POST /sessions} request. 122 * 123 * @param optionsFactory non-null function from session id to options 124 * @return this builder 125 * @throws NullPointerException if {@code optionsFactory} is null 126 */ 127 public Builder withOptionsFactory(Function<String, SessionOptions> optionsFactory) { 128 this.optionsFactory = 129 Objects.requireNonNull(optionsFactory, "optionsFactory must not be null"); 130 return this; 131 } 132 133 /** 134 * Override the default {@link JsonMapper}. Most callers should leave this alone. 135 * 136 * @param objectMapper non-null mapper 137 * @return this builder 138 */ 139 public Builder withObjectMapper(ObjectMapper objectMapper) { 140 this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null"); 141 return this; 142 } 143 144 /** 145 * Override the URL prefix the service is mounted under (default {@code /v1}). 146 * 147 * @param routePrefix non-blank prefix; leading slash required 148 * @return this builder 149 */ 150 public Builder withRoutePrefix(String routePrefix) { 151 Objects.requireNonNull(routePrefix, "routePrefix must not be null"); 152 if (Strings.isBlank(routePrefix) || !routePrefix.startsWith("/")) { 153 throw new IllegalArgumentException( 154 "routePrefix must be non-blank and start with '/'; got '" + routePrefix + "'"); 155 } 156 this.routePrefix = routePrefix; 157 return this; 158 } 159 160 /** 161 * Bind the HTTP listen port. Default {@code 0} (kernel-assigned). Tests should leave this at 162 * the default; production deployments typically pass an explicit port via configuration. 163 * 164 * @param port non-negative port (0 = random) 165 * @return this builder 166 */ 167 public Builder withPort(int port) { 168 if (port < 0 || port > 65535) { 169 throw new IllegalArgumentException("port must be in [0, 65535], got " + port); 170 } 171 this.port = port; 172 return this; 173 } 174 175 /** 176 * Bind the HTTP listen host. Default {@code 127.0.0.1} (loopback only — fail-secure). Pass 177 * {@code "0.0.0.0"} or a specific external interface to expose the service beyond the host; 178 * note that the HTTP routes are unauthenticated and the {@code POST /sessions} surface creates 179 * real model-spending sessions, so external binds should sit behind authenticated fronting 180 * infrastructure. 181 * 182 * @param host non-blank host 183 * @return this builder 184 */ 185 public Builder withHost(String host) { 186 Objects.requireNonNull(host, "host must not be null"); 187 if (Strings.isBlank(host)) { 188 throw new IllegalArgumentException("host must not be blank"); 189 } 190 this.host = host; 191 return this; 192 } 193 194 /** 195 * Build and start the server. 196 * 197 * @return the started server 198 * @throws IllegalStateException if {@code registry} or {@code optionsFactory} was never set 199 */ 200 public RuntimeServer build() { 201 if (registry == null) { 202 throw new IllegalStateException("registry is required — call withRegistry before build"); 203 } 204 if (optionsFactory == null) { 205 throw new IllegalStateException( 206 "optionsFactory is required — call withOptionsFactory before build"); 207 } 208 var mapper = objectMapper != null ? objectMapper : JsonMapper.builder().build(); 209 var service = new AgentHttpService(registry, optionsFactory, mapper, routePrefix); 210 var jacksonSupport = JacksonSupport.create(mapper); 211 var server = 212 WebServer.builder() 213 .host(host) 214 .port(port) 215 .mediaContext( 216 mc -> mc.mediaSupportsDiscoverServices(false).addMediaSupport(jacksonSupport)) 217 .routing((HttpRouting.Builder routing) -> routing.register(routePrefix, service)) 218 .build() 219 .start(); 220 return new RuntimeServer(server, registry); 221 } 222 } 223}