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}