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}