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 private SessionRegistry registry; 086 private Function<String, SessionOptions> optionsFactory; 087 private ObjectMapper objectMapper; 088 private String routePrefix = "/v1"; 089 private int port = 0; 090 private String host = "0.0.0.0"; 091 092 private Builder() {} 093 094 /** 095 * Bind the registry the service exposes. 096 * 097 * @param registry non-null registry 098 * @return this builder 099 * @throws NullPointerException if {@code registry} is null 100 */ 101 public Builder withRegistry(SessionRegistry registry) { 102 this.registry = Objects.requireNonNull(registry, "registry must not be null"); 103 return this; 104 } 105 106 /** 107 * Bind the options factory the service calls for every {@code POST /sessions} request. 108 * 109 * @param optionsFactory non-null function from session id to options 110 * @return this builder 111 * @throws NullPointerException if {@code optionsFactory} is null 112 */ 113 public Builder withOptionsFactory(Function<String, SessionOptions> optionsFactory) { 114 this.optionsFactory = 115 Objects.requireNonNull(optionsFactory, "optionsFactory must not be null"); 116 return this; 117 } 118 119 /** 120 * Override the default {@link JsonMapper}. Most callers should leave this alone. 121 * 122 * @param objectMapper non-null mapper 123 * @return this builder 124 */ 125 public Builder withObjectMapper(ObjectMapper objectMapper) { 126 this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null"); 127 return this; 128 } 129 130 /** 131 * Override the URL prefix the service is mounted under (default {@code /v1}). 132 * 133 * @param routePrefix non-blank prefix; leading slash required 134 * @return this builder 135 */ 136 public Builder withRoutePrefix(String routePrefix) { 137 Objects.requireNonNull(routePrefix, "routePrefix must not be null"); 138 if (Strings.isBlank(routePrefix) || !routePrefix.startsWith("/")) { 139 throw new IllegalArgumentException( 140 "routePrefix must be non-blank and start with '/'; got '" + routePrefix + "'"); 141 } 142 this.routePrefix = routePrefix; 143 return this; 144 } 145 146 /** 147 * Bind the HTTP listen port. Default {@code 0} (kernel-assigned). Tests should leave this at 148 * the default; production deployments typically pass an explicit port via configuration. 149 * 150 * @param port non-negative port (0 = random) 151 * @return this builder 152 */ 153 public Builder withPort(int port) { 154 if (port < 0 || port > 65535) { 155 throw new IllegalArgumentException("port must be in [0, 65535], got " + port); 156 } 157 this.port = port; 158 return this; 159 } 160 161 /** 162 * Bind the HTTP listen host. Default {@code 0.0.0.0}. 163 * 164 * @param host non-blank host 165 * @return this builder 166 */ 167 public Builder withHost(String host) { 168 Objects.requireNonNull(host, "host must not be null"); 169 if (Strings.isBlank(host)) { 170 throw new IllegalArgumentException("host must not be blank"); 171 } 172 this.host = host; 173 return this; 174 } 175 176 /** 177 * Build and start the server. 178 * 179 * @return the started server 180 * @throws IllegalStateException if {@code registry} or {@code optionsFactory} was never set 181 */ 182 public RuntimeServer build() { 183 if (registry == null) { 184 throw new IllegalStateException("registry is required — call withRegistry before build"); 185 } 186 if (optionsFactory == null) { 187 throw new IllegalStateException( 188 "optionsFactory is required — call withOptionsFactory before build"); 189 } 190 var mapper = objectMapper != null ? objectMapper : JsonMapper.builder().build(); 191 var service = new AgentHttpService(registry, optionsFactory, mapper, routePrefix); 192 var jacksonSupport = JacksonSupport.create(mapper); 193 var server = 194 WebServer.builder() 195 .host(host) 196 .port(port) 197 .mediaContext( 198 mc -> mc.mediaSupportsDiscoverServices(false).addMediaSupport(jacksonSupport)) 199 .routing((HttpRouting.Builder routing) -> routing.register(routePrefix, service)) 200 .build() 201 .start(); 202 return new RuntimeServer(server, registry); 203 } 204 } 205}