diff --git a/.env.example b/.env.example index b0145de..b709c6a 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,7 @@ LLM_API_KEY= LLM_BATCH= # true halves the price and makes scoring asynchronous LLM_TIMEOUT= # ISO-8601, e.g. PT120S. One request to a model; empty is PT120S LLM_CONCURRENCY= # adverts the model-bound stages work at once; empty is 1, at most the DB pool (10) +LLM_BILLED= # true | false; empty is billed for anthropic only LLM_MODEL_EXTRACTION= # reads a document a source's rules could not; empty falls back to SCORING LLM_MODEL_CONTENT= # labels an advert's blocks; empty means LLM_MODEL_SCORING LLM_MODEL_FIELDS= # reads start, duration, apply-by; empty means LLM_MODEL_SCORING @@ -42,6 +43,12 @@ LLM_MODEL_EMBEDDING= # >= 2000 dims, for deduplication's simi LLM_MODEL_CHAT= # the chat in the drawer, must call tools; empty means LLM_MODEL_SCORING CHAT_MAX_CALLS_PER_DAY= # the chat's own daily ceiling, beside the pipeline's; empty is 200 CHAT_MAX_TOOL_ROUNDS= # tool calls one chat turn may make before it answers; empty is 6 +CHAT_LLM_PROVIDER= # the chat's optional second connection: anthropic | ollama | openai-compatible; empty is none +CHAT_LLM_BASE_URL= # required for a hosted kind +CHAT_LLM_API_KEY= # required for a hosted kind; Ollama needs none +CHAT_LLM_BILLED= # true | false; empty is billed for anthropic only +CHAT_LLM_TIMEOUT= # ISO-8601, one request on the second connection; empty is PT120S +CHAT_LLM_MAX_CALLS_PER_DAY= # the second connection's own daily ceiling, beside the chat's; empty is 50 CHAT_SUGGEST_NEW_OFFERS_MIN= # the empty chat suggests the last run's offers from this many; empty is 1 CHAT_SUGGEST_DEADLINE_DAYS= # ... deadlines within this many days; empty is 7 CHAT_SUGGEST_NO_REPLY_DAYS= # ... sent applications without a reply for this many days; empty is 14 diff --git a/CHANGELOG.md b/CHANGELOG.md index 00945c4..8e1eb95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,37 @@ may change in any release. See the status note in the README. ## [Unreleased] +## [0.5.5] — 2026-10-12 + +The chat can answer with another model per question — a local one or, through an optional second +connection, a hosted one with a daily ceiling of its own — and an iPad gets its home-screen icon. +The configuration gains the optional `chat.connection` block and `llm.billed`; an installation without +them runs as before. + +### Added + +- **The chat's model is picked per question.** A select beside the composer lists the models each configured + connection serves and can call tools with, grouped by connection; billed ones are marked there and under every + answer they wrote. The next question may go to another model, which reads the conversation so far, and + regenerate may use another model. A new conversation always starts on the configured default. +- **An optional second connection for the chat**, `chat.connection` (`CHAT_LLM_*`), beside `llm`: another + provider kind, base URL and key, read by the chat alone, with a daily ceiling of its own + (`CHAT_LLM_MAX_CALLS_PER_DAY`, default 50) on top of the chat's. `LLM_BILLED` and `CHAT_LLM_BILLED` decide + the billed marker. New endpoint `GET /api/v1/chat/models`; `GET /api/v1/chat/status` names the second + connection's calls. + +### Changed + +- **The chat panel loads on its first open.** The drawer's content and its libraries left the initial bundle + (1.00 MB → 868 kB); the header button, its living mark and the minibar stay in the shell, and a reload with + `?chat=…` still opens the drawer with its content. + +### Fixed + +- **Every apple-touch-icon variant answers with the touch icon.** iOS asks for fixed root paths, an iPad's + 152 and 167 sizes among them; nginx handed those to the SPA fallback, and an iPad's home-screen icon fell + back to a letter. + ## [0.5.4] — 2026-10-11 The workflow screen's live run holds through a dropped heartbeat and a skewed clock, links in the @@ -1702,7 +1733,8 @@ Found while building the demo, all of them in paths only a container exercises: left six. - The shortlist card printed the description's Markdown syntax in its teaser. -[Unreleased]: https://github.com/codeministry/leadgen/compare/v0.5.4...HEAD +[Unreleased]: https://github.com/codeministry/leadgen/compare/v0.5.5...HEAD +[0.5.5]: https://github.com/codeministry/leadgen/releases/tag/v0.5.5 [0.5.4]: https://github.com/codeministry/leadgen/releases/tag/v0.5.4 [0.5.3]: https://github.com/codeministry/leadgen/releases/tag/v0.5.3 [0.5.2]: https://github.com/codeministry/leadgen/releases/tag/v0.5.2 diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 38d5aa7..cf8c345 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -14,7 +14,7 @@ plugins { } group = "de.codeministry" -version = "0.5.4" +version = "0.5.5" /** * Java 25 through the toolchain rather than the ambient JDK: Gradle runs on diff --git a/backend/src/main/java/de/codeministry/leadgen/ConfigurationBanner.java b/backend/src/main/java/de/codeministry/leadgen/ConfigurationBanner.java index b1e842a..87a08cd 100644 --- a/backend/src/main/java/de/codeministry/leadgen/ConfigurationBanner.java +++ b/backend/src/main/java/de/codeministry/leadgen/ConfigurationBanner.java @@ -14,6 +14,7 @@ import de.codeministry.leadgen.config.DotEnv; import de.codeministry.leadgen.config.Secrets; import de.codeministry.leadgen.config.model.PipelineConfig; +import de.codeministry.leadgen.llm.Connections; import de.codeministry.leadgen.llm.ModelChoice; import java.nio.file.Files; import java.nio.file.Path; @@ -170,6 +171,7 @@ String describe(DotEnv dotenv) { + (Files.isDirectory(properties.configDirectory()) ? "" : " (missing, defaults apply)"), Origin.RESOLVED)); entries.addAll(modelChoices()); + entries.addAll(secondConnection()); return render(entries, dotenv, ignored); } @@ -199,6 +201,36 @@ private List modelChoices() { chosen("llm.models.chat", ModelChoice.chat(models), models == null ? null : models.chat())); } + /** + * Three rows for the chat's second connection when {@code chat.connection} names a kind, and + * none otherwise: the kind and whether it is billed as {@link Connections} resolves it, where + * the requests go, and whether a key is set. The base URL loses any {@code user:password@} and the + * key is only ever {@link Secrets#mask masked} — a hosted connection's key is exactly what this + * box must not print, and "is one set" is the question it answers instead. + */ + private List secondConnection() { + Optional second; + try { + second = Connections.second(config.snapshot().application()); + } catch (RuntimeException e) { + return List.of(); + } + return second.map(connection -> List.of( + new Entry( + "chat.connection → llm", + connection.kind() + " · " + (connection.billed() ? "billed" : "not billed"), + Origin.CHOSEN), + new Entry( + "chat.connection → llm url", + Secrets.mask("base_url", connection.llm().baseUrl()), + Origin.CHOSEN), + new Entry( + "chat.connection → llm key", + Secrets.mask("api_key", connection.llm().apiKey()), + Origin.CHOSEN))) + .orElse(List.of()); + } + private static Entry chosen(String key, Optional model, String configured) { return new Entry( key + " → used", diff --git a/backend/src/main/java/de/codeministry/leadgen/LeadGenRuntimeHints.java b/backend/src/main/java/de/codeministry/leadgen/LeadGenRuntimeHints.java index bf4d70f..3ab1c6c 100644 --- a/backend/src/main/java/de/codeministry/leadgen/LeadGenRuntimeHints.java +++ b/backend/src/main/java/de/codeministry/leadgen/LeadGenRuntimeHints.java @@ -22,14 +22,19 @@ import de.codeministry.leadgen.chat.ChatTurnStarted; import de.codeministry.leadgen.chat.ConversationSummary; import de.codeministry.leadgen.chat.ConversationView; +import de.codeministry.leadgen.chat.ModelPick; import de.codeministry.leadgen.chat.NewContext; import de.codeministry.leadgen.chat.NewConversation; import de.codeministry.leadgen.chat.NewTurn; import de.codeministry.leadgen.chat.PastTurn; +import de.codeministry.leadgen.chat.RegenerateTurn; import de.codeministry.leadgen.chat.RenameConversation; import de.codeministry.leadgen.chat.StatisticsSource; import de.codeministry.leadgen.chat.TurnLedger; import de.codeministry.leadgen.chat.TurnView; +import de.codeministry.leadgen.chat.catalog.ChatModelsView; +import de.codeministry.leadgen.chat.catalog.ConnectionView; +import de.codeministry.leadgen.chat.catalog.ModelView; import de.codeministry.leadgen.chat.suggest.FollowUp; import de.codeministry.leadgen.chat.suggest.Suggestion; import de.codeministry.leadgen.chat.suggest.SuggestionCandidate; @@ -126,6 +131,9 @@ private static void registerChatRecords(RuntimeHints hints) { BulkDeleted.class, ChatCapabilityView.class, ChatContextItem.class, + ChatModelsView.class, + ConnectionView.class, + ModelView.class, ChatDone.class, ChatError.class, ChatSource.class, @@ -143,8 +151,10 @@ private static void registerChatRecords(RuntimeHints hints) { SuggestionScope.class, SuggestionThresholds.class, NewConversation.class, + ModelPick.class, NewTurn.class, PastTurn.class, + RegenerateTurn.class, RenameConversation.class, StatisticsSource.class, StatisticsSource.Row.class, diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/ChatBudget.java b/backend/src/main/java/de/codeministry/leadgen/chat/ChatBudget.java index c1cd355..c83d723 100644 --- a/backend/src/main/java/de/codeministry/leadgen/chat/ChatBudget.java +++ b/backend/src/main/java/de/codeministry/leadgen/chat/ChatBudget.java @@ -10,13 +10,17 @@ import de.codeministry.leadgen.config.ConfigRegistry; import de.codeministry.leadgen.config.model.PipelineConfig; +import de.codeministry.leadgen.llm.Connections; import java.time.LocalDate; +import java.util.OptionalInt; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; /** * The chat's own ceilings: how many requests a day it may send, and how many tool rounds one turn @@ -36,6 +40,12 @@ * by default wherever a scoring model exists. Without a round bound, a model that keeps asking for * tools looped for ever on a pool thread; without a daily ceiling, nothing metered it. * + *

The second connection has a ceiling of its own, beside the chat's (spec 025, ISC-510). + * A call on {@code chat.connection} takes one from {@code chat.max_calls_per_day} and one from + * {@code chat.connection.max_calls_per_day}, in one transaction: when either refuses, the other is + * rolled back, so a refused call counts on neither. A call on the primary takes only the chat's. The + * second ceiling reads {@link #DEFAULT_SECOND_CALLS_PER_DAY} where it is absent, never no ceiling. + * *

What "no" means is the turn's. A refused call or a spent round count ends the turn with * its reason ({@link ChatErrorReason#BUDGET}, {@link ChatErrorReason#ROUNDS}) rather than with an * answer the model never finished; this class only answers the question. @@ -51,6 +61,12 @@ public class ChatBudget { /** {@code chat.max_tool_rounds} when the key or the whole {@code chat:} block is absent. */ public static final int DEFAULT_TOOL_ROUNDS = 6; + /** + * {@code chat.connection.max_calls_per_day} when the key is absent; the shipped + * {@code pipeline.yaml} names the same default for its placeholder. + */ + public static final int DEFAULT_SECOND_CALLS_PER_DAY = 50; + /** * Check and increment in one statement, as in {@code LlmBudget}: between a {@code SELECT} and * an {@code UPDATE} two turns could both take the last call. @@ -62,11 +78,23 @@ ON CONFLICT (day) DO UPDATE SET calls = chat_call_budget.calls + 1 RETURNING calls """; + /** The same one-statement take, per connection, for the second connection's own ceiling. */ + private static final String TAKE_CONNECTION = """ + INSERT INTO chat_connection_call_budget (day, connection, calls) VALUES (current_date, :connection, 1) + ON CONFLICT (day, connection) DO UPDATE SET calls = chat_connection_call_budget.calls + 1 + WHERE chat_connection_call_budget.calls < :limit + RETURNING calls + """; + /** The day a spent budget was logged, so it is one line and not one per refused call. */ private final AtomicReference announced = new AtomicReference<>(); + /** The same for the second connection's ceiling, so neither spent day hides the other's line. */ + private final AtomicReference announcedSecond = new AtomicReference<>(); + private final ConfigRegistry config; private final JdbcClient jdbc; + private final PlatformTransactionManager transactionManager; /** * Takes one call from today's chat allowance. Asked before every model call of a turn — the @@ -75,9 +103,53 @@ ON CONFLICT (day) DO UPDATE SET calls = chat_call_budget.calls + 1 * @return true when the call may be sent; false ends the turn with {@link ChatErrorReason#BUDGET}. */ public boolean take() { + return take(Connections.Name.PRIMARY); + } + + /** + * Takes one call on {@code connection}: on the primary from the chat's allowance alone, on the + * second from the chat's and the connection's own, both or neither (ISC-510). + * + * @return true when the call may be sent; false ends the turn with {@link ChatErrorReason#BUDGET}. + */ + public boolean take(Connections.Name connection) { + if (connection != Connections.Name.SECOND) { + return takeChat(); + } + int secondLimit = secondLimitOrDefault(); + if (secondLimit <= 0) { + announceOnce( + announcedSecond, + "chat.connection.max_calls_per_day is {}, so the chat asks nothing of it today", + secondLimit); + return false; + } + Boolean taken = new TransactionTemplate(transactionManager).execute(status -> { + if (!takeChat()) { + return false; + } + boolean second = jdbc.sql(TAKE_CONNECTION) + .param("connection", connection.name()) + .param("limit", secondLimit) + .query(Integer.class) + .optional() + .isPresent(); + if (!second) { + // The chat's call taken above is given back: a refused call counts on neither. + status.setRollbackOnly(); + announceOnce( + announcedSecond, "chat.connection.max_calls_per_day of {} is spent for today", secondLimit); + } + return second; + }); + return Boolean.TRUE.equals(taken); + } + + /** Takes one call from the chat's own counter, as every call on any connection does. */ + private boolean takeChat() { int limit = limit(); if (limit <= 0) { - announceOnce("chat.max_calls_per_day is {}, so the chat asks nothing of a model today", limit); + announceOnce(announced, "chat.max_calls_per_day is {}, so the chat asks nothing of a model today", limit); return false; } boolean taken = jdbc.sql(TAKE) @@ -86,7 +158,7 @@ public boolean take() { .optional() .isPresent(); if (!taken) { - announceOnce("chat.max_calls_per_day of {} is spent for today", limit); + announceOnce(announced, "chat.max_calls_per_day of {} is spent for today", limit); } return taken; } @@ -98,6 +170,37 @@ public int used() { .single(); } + /** Today's count on {@code connection}: the chat's own for the primary, the connection's own for the second. */ + public int used(Connections.Name connection) { + if (connection != Connections.Name.SECOND) { + return used(); + } + return jdbc.sql(""" + SELECT coalesce((SELECT calls FROM chat_connection_call_budget + WHERE day = current_date AND connection = :connection), 0) + """) + .param("connection", connection.name()) + .query(Integer.class) + .single(); + } + + /** + * {@code chat.connection.max_calls_per_day} as it resolves now; empty when no second connection + * is configured, the shipped default where the key is absent. + */ + public OptionalInt secondLimit() { + PipelineConfig.ChatConnection connection = chat().connection(); + return connection == null || !connection.configured() + ? OptionalInt.empty() + : OptionalInt.of(secondLimitOrDefault()); + } + + private int secondLimitOrDefault() { + PipelineConfig.ChatConnection connection = chat().connection(); + Integer configured = connection == null ? null : connection.maxCallsPerDay(); + return configured == null ? DEFAULT_SECOND_CALLS_PER_DAY : configured; + } + /** {@code chat.max_calls_per_day} as it resolves now, the shipped default where it is absent. */ public int limit() { Integer configured = chat().maxCallsPerDay(); @@ -124,9 +227,9 @@ private PipelineConfig.Chat chat() { return chat == null ? new PipelineConfig.Chat(null, null) : chat; } - private void announceOnce(String message, Object argument) { + private static void announceOnce(AtomicReference day, String message, Object argument) { LocalDate today = LocalDate.now(); - if (!today.equals(announced.getAndSet(today))) { + if (!today.equals(day.getAndSet(today))) { log.warn(message, argument); } } diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/ChatStatusView.java b/backend/src/main/java/de/codeministry/leadgen/chat/ChatStatusView.java index c91539e..7be6bde 100644 --- a/backend/src/main/java/de/codeministry/leadgen/chat/ChatStatusView.java +++ b/backend/src/main/java/de/codeministry/leadgen/chat/ChatStatusView.java @@ -17,5 +17,16 @@ * @param callsUsed today's model calls, against {@code callsLimit}. * @param callsLimit {@code chat.max_calls_per_day} as it resolves; {@code 0} asks nothing of a model. * @param toolRounds {@code chat.max_tool_rounds} as it resolves: how often one turn may call tools. + * @param secondCalls today's calls on the second connection, against {@code secondLimit}; null + * without a second connection (spec 025, ISC-510). + * @param secondLimit {@code chat.connection.max_calls_per_day} as it resolves; null without a second + * connection. */ -public record ChatStatusView(String model, int callsUsed, int callsLimit, int toolRounds) {} +public record ChatStatusView( + String model, int callsUsed, int callsLimit, int toolRounds, Integer secondCalls, Integer secondLimit) { + + /** The status of a chat with no second connection. */ + public ChatStatusView(String model, int callsUsed, int callsLimit, int toolRounds) { + this(model, callsUsed, callsLimit, toolRounds, null, null); + } +} diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/ChatTurnService.java b/backend/src/main/java/de/codeministry/leadgen/chat/ChatTurnService.java index e9a62cc..dbaffc8 100644 --- a/backend/src/main/java/de/codeministry/leadgen/chat/ChatTurnService.java +++ b/backend/src/main/java/de/codeministry/leadgen/chat/ChatTurnService.java @@ -22,6 +22,7 @@ import de.codeministry.leadgen.config.ConfigProperties; import de.codeministry.leadgen.config.ConfigRegistry; import de.codeministry.leadgen.llm.ChatModels; +import de.codeministry.leadgen.llm.Connections; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import java.io.IOException; @@ -212,7 +213,18 @@ public Duration timeout() { * @throws java.util.concurrent.RejectedExecutionException when the pool and its queue are full */ public void start(long conversationId, String question, Consumer sink, Runnable finished) { - start(conversationId, question, null, sink, finished); + start(conversationId, question, ModelPick.DEFAULT, sink, finished); + } + + /** + * Starts a turn on {@code pick} — the model and connection the drawer's select named, or the + * default — exactly as {@link #start(long, String, Consumer, Runnable)} does (spec 025, ISC-507). + * + * @throws java.util.concurrent.RejectedExecutionException when the pool and its queue are full + */ + public void start( + long conversationId, String question, ModelPick pick, Consumer sink, Runnable finished) { + start(conversationId, question, pick, null, sink, finished); } /** @@ -224,8 +236,17 @@ public void start(long conversationId, String question, Consumer sink * @throws java.util.concurrent.RejectedExecutionException when the pool and its queue are full */ public boolean regenerate(long conversationId, long turnId, Consumer sink, Runnable finished) { + return regenerate(conversationId, turnId, ModelPick.DEFAULT, sink, finished); + } + + /** + * {@link #regenerate(long, long, Consumer, Runnable)} on {@code pick}: the new turn names the + * model it was asked of, and the replaced turn keeps its own (spec 025, ISC-508). + */ + public boolean regenerate( + long conversationId, long turnId, ModelPick pick, Consumer sink, Runnable finished) { Optional question = conversations.question(conversationId, turnId); - question.ifPresent(asked -> start(conversationId, asked, turnId, sink, finished)); + question.ifPresent(asked -> start(conversationId, asked, pick, turnId, sink, finished)); return question.isPresent(); } @@ -306,29 +327,60 @@ void sweep() { } private void start( - long conversationId, String question, Long replaces, Consumer sink, Runnable finished) { + long conversationId, + String question, + ModelPick pick, + Long replaces, + Consumer sink, + Runnable finished) { turns.execute(() -> { try { - ask(conversationId, question, replaces, sink); + ask(conversationId, question, pick, replaces, sink); } finally { finished.run(); } }); } - /** Runs one turn on the calling thread. {@link #start} is the way in from a request. */ + /** Runs one turn on the default model, on the calling thread. {@link #start} is the way in from a request. */ public void ask(long conversationId, String question, Consumer sink) { - ask(conversationId, question, null, sink); + ask(conversationId, question, ModelPick.DEFAULT, sink); } - private void ask(long conversationId, String question, Long replaces, Consumer sink) { + /** Runs one turn on {@code pick}, on the calling thread. {@link #start} is the way in from a request. */ + public void ask(long conversationId, String question, ModelPick pick, Consumer sink) { + ask(conversationId, question, pick, null, sink); + } + + /** + * Who answers a turn asked with {@code pick}: the pick itself, or for the default the chat model + * on the primary connection — {@code llm.models.chat}, else {@code scoring} (spec 025, ISC-507). + * Checking a non-default pick against what the connection listed is the controller's, before a + * turn exists; a connection name that is neither of the two is refused here as a broken start. + */ + private Answerer answerer(ModelPick pick) { + if (pick == null || pick.isDefault()) { + String model = capability.model().orElse(null); + return new Answerer(model == null ? null : Connections.Name.PRIMARY, model); + } + return new Answerer(Connections.Name.valueOf(pick.connection()), pick.model()); + } + + /** The connection and the model a turn is asked of, resolved; both null when no model is configured. */ + private record Answerer(Connections.Name connection, String model) {} + + private void ask(long conversationId, String question, ModelPick pick, Long replaces, Consumer sink) { Turn turn = null; try { - String modelName = capability.model().orElse(null); - long turnId = conversations.startTurn(conversationId, question, modelName, replaces); - turn = new Turn(turnId, conversationId, question, modelName, sink); + Answerer answerer = answerer(pick); + long turnId = conversations.startTurn( + conversationId, question, answerer.model(), answerer.connection(), replaces); + turn = new Turn(turnId, conversationId, question, answerer, sink); running.put(turnId, turn); - sink.accept(new ChatTurnStarted(turnId, modelName)); + sink.accept(new ChatTurnStarted( + turnId, + answerer.model(), + answerer.connection() == null ? null : answerer.connection().name())); turn.run(); } catch (RuntimeException e) { if (turn == null) { @@ -365,7 +417,7 @@ private final class Turn { private final long id; private final long conversationId; private final String question; - private final String modelName; + private final Answerer answerer; private final Consumer sink; private final TurnLedger ledger = new TurnLedger(); private final CitationFilter filter; @@ -401,11 +453,11 @@ private final class Turn { private final Sinks.Empty stopSignal = Sinks.empty(); - Turn(long id, long conversationId, String question, String modelName, Consumer sink) { + Turn(long id, long conversationId, String question, Answerer answerer, Consumer sink) { this.id = id; this.conversationId = conversationId; this.question = question; - this.modelName = modelName; + this.answerer = answerer; this.sink = sink; this.filter = new CitationFilter(ledger, this::reachable); } @@ -424,13 +476,19 @@ private Duration remaining() { } void run() { - ChatModel model = modelName == null + // Exactly the connection and the model the turn was asked with, and no other: a model + // that cannot be built ends the turn, it is never swapped for one that can (ISC-509). + ChatModel model = answerer.model() == null ? null - : chatModels - .of(config.snapshot().application().llm(), modelName) + : Connections.of(config.snapshot().application(), answerer.connection()) + .flatMap(connection -> chatModels.of(connection.llm(), answerer.model())) .orElse(null); if (model == null) { - end(ChatErrorReason.MODEL, "no chat model can be reached with the configured llm block"); + end( + ChatErrorReason.MODEL, + answerer.connection() == Connections.Name.SECOND + ? "no chat model can be reached with the configured chat.connection block" + : "no chat model can be reached with the configured llm block"); return; } ChatOptions options = options(model); @@ -452,8 +510,10 @@ void run() { outOfTime(); return; } - if (!budget.take()) { - end(ChatErrorReason.BUDGET, "chat.max_calls_per_day is spent for today"); + // Each round on the connection the turn was asked of: the second connection's + // own ceiling beside the chat's, the primary's against the chat's alone (ISC-510). + if (!budget.take(answerer.connection())) { + end(ChatErrorReason.BUDGET, spent()); return; } StringBuilder said = new StringBuilder(); @@ -538,6 +598,13 @@ void run() { complete(ChatTurnState.DONE); } + /** Which ceiling refused the call: the chat's own, or else the second connection's. */ + private String spent() { + return answerer.connection() == Connections.Name.SECOND && budget.used() < budget.limit() + ? "chat.connection.max_calls_per_day is spent for today" + : "chat.max_calls_per_day is spent for today"; + } + void stop() { stopped = true; stopSignal.tryEmitEmpty(); diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/ChatTurnStarted.java b/backend/src/main/java/de/codeministry/leadgen/chat/ChatTurnStarted.java index 51d9ab7..9b63ede 100644 --- a/backend/src/main/java/de/codeministry/leadgen/chat/ChatTurnStarted.java +++ b/backend/src/main/java/de/codeministry/leadgen/chat/ChatTurnStarted.java @@ -14,8 +14,15 @@ * @param turnId the stored turn, already written with state {@code STREAMING}. * @param model the model answering it, as {@code chat_turn.model} stores it, so the live turn's * status names it before a reload does; null where none is configured. + * @param connection the connection it answers on, {@code PRIMARY} or {@code SECOND} (spec 025, + * ISC-507); null where no model is configured. */ -public record ChatTurnStarted(long turnId, String model) implements ChatEvent { +public record ChatTurnStarted(long turnId, String model, String connection) implements ChatEvent { + + /** A turn started before its connection is known. */ + public ChatTurnStarted(long turnId, String model) { + this(turnId, model, null); + } @Override public String event() { diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/ConversationRepository.java b/backend/src/main/java/de/codeministry/leadgen/chat/ConversationRepository.java index 3eba042..3713bb6 100644 --- a/backend/src/main/java/de/codeministry/leadgen/chat/ConversationRepository.java +++ b/backend/src/main/java/de/codeministry/leadgen/chat/ConversationRepository.java @@ -15,6 +15,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; +import de.codeministry.leadgen.llm.Connections; import java.sql.ResultSet; import java.sql.SQLException; import java.text.Normalizer; @@ -82,8 +83,8 @@ WHERE conversation_id IN (:ids) """; private static final String TURNS = """ - SELECT id, question, answer_md, state, replaces_turn_id, model, created_at, citations, - end_reason, finished_at + SELECT id, question, answer_md, state, replaces_turn_id, model, connection, created_at, + citations, end_reason, finished_at FROM chat_turn WHERE conversation_id = :id ORDER BY ordinal @@ -107,10 +108,10 @@ WHERE conversation_id IN (:ids) """; private static final String START_TURN = """ - INSERT INTO chat_turn (conversation_id, ordinal, question, model, replaces_turn_id) + INSERT INTO chat_turn (conversation_id, ordinal, question, model, connection, replaces_turn_id) VALUES (:conversation, (SELECT coalesce(max(ordinal), 0) + 1 FROM chat_turn WHERE conversation_id = :conversation), - :question, :model, CAST(:replaces AS bigint)) + :question, :model, :connection, CAST(:replaces AS bigint)) RETURNING id """; @@ -372,7 +373,14 @@ public long startTurn(long conversationId, String question, String model) { return startTurn(conversationId, question, model, null); } + /** A turn on the primary connection, as every turn was started before spec 025. */ + @Transactional + public long startTurn(long conversationId, String question, String model, Long replaces) { + return startTurn(conversationId, question, model, Connections.Name.PRIMARY, replaces); + } + /** + * @param connection the connection the turn is asked on, stored by its name (spec 025, ISC-507). * @param replaces the turn this one answers again, set by regenerate; null for a new question. * *

The conversation is touched before the turn is inserted, and that order is the @@ -382,7 +390,8 @@ public long startTurn(long conversationId, String question, String model) { * max(ordinal)} and the second would fail on {@code UNIQUE (conversation_id, ordinal)}. */ @Transactional - public long startTurn(long conversationId, String question, String model, Long replaces) { + public long startTurn( + long conversationId, String question, String model, Connections.Name connection, Long replaces) { jdbc.sql(TOUCH) .param("conversation", conversationId) .param("title", title(question)) @@ -391,6 +400,7 @@ public long startTurn(long conversationId, String question, String model, Long r .param("conversation", conversationId) .param("question", question) .param("model", model) + .param("connection", connection == null ? null : connection.name()) .param("replaces", replaces) .query(Long.class) .single(); @@ -670,6 +680,7 @@ private static StoredTurn stored(ResultSet rs, int index) throws SQLException { ChatTurnState.valueOf(rs.getString("state")), (Long) rs.getObject("replaces_turn_id"), rs.getString("model"), + rs.getString("connection"), rs.getTimestamp("created_at").toInstant(), citations(rs.getString("citations")), rs.getString("end_reason") == null ? null : ChatErrorReason.valueOf(rs.getString("end_reason")), @@ -932,6 +943,7 @@ private record StoredTurn( ChatTurnState state, Long replaces, String model, + String connection, java.time.Instant createdAt, List citations, ChatErrorReason endReason, @@ -939,7 +951,18 @@ private record StoredTurn( TurnView view(List steps, List sources) { return new TurnView( - id, question, answer, state, steps, sources, replaces, model, createdAt, endReason, finishedAt); + id, + question, + answer, + state, + steps, + sources, + replaces, + model, + connection, + createdAt, + endReason, + finishedAt); } } } diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/ModelPick.java b/backend/src/main/java/de/codeministry/leadgen/chat/ModelPick.java new file mode 100644 index 0000000..e309a63 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/ModelPick.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat; + +/** + * Which model a turn is asked of, as the drawer's select sent it (spec 025, ISC-507). + * + *

Both halves or neither: a pick names a connection, {@code PRIMARY} or {@code SECOND}, and a + * model that connection listed in {@code GET /api/v1/chat/models}. Neither means the default, the + * chat model on the primary connection, which is what every turn asked before this record existed. + * + * @param connection the connection's name, or null for the default. + * @param model the model's name as the connection reported it, or null for the default. + */ +public record ModelPick(String connection, String model) { + + /** The default: no pick, the chat model on the primary connection. */ + public static final ModelPick DEFAULT = new ModelPick(null, null); + + /** Whether this pick leaves the choice to the default. */ + public boolean isDefault() { + return blank(connection) && blank(model); + } + + /** Whether exactly one half was sent, which no select produces. */ + public boolean isPartial() { + return blank(connection) != blank(model); + } + + private static boolean blank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/NewTurn.java b/backend/src/main/java/de/codeministry/leadgen/chat/NewTurn.java index 14bb214..4e2639b 100644 --- a/backend/src/main/java/de/codeministry/leadgen/chat/NewTurn.java +++ b/backend/src/main/java/de/codeministry/leadgen/chat/NewTurn.java @@ -14,6 +14,19 @@ /** * The body of {@code POST /api/v1/chat/conversations/{id}/turns}. * - * @param question what the operator typed. + * @param question what the operator typed. + * @param connection the connection the select picked, or null for the default (spec 025, ISC-507). + * @param model the model the select picked, or null for the default. */ -public record NewTurn(@NotBlank @Size(max = 4000) String question) {} +public record NewTurn(@NotBlank @Size(max = 4000) String question, String connection, String model) { + + /** A question with no pick, as every turn was asked before spec 025. */ + public NewTurn(String question) { + this(question, null, null); + } + + /** The pick this turn carries. */ + public ModelPick pick() { + return new ModelPick(connection, model); + } +} diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/RegenerateTurn.java b/backend/src/main/java/de/codeministry/leadgen/chat/RegenerateTurn.java new file mode 100644 index 0000000..c4a5880 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/RegenerateTurn.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat; + +/** + * The optional body of {@code POST …/turns/{turnId}/regenerate}: ask the question again, on the + * same default or on another model (spec 025, ISC-508). An absent or empty body is the default. + * + * @param connection the connection the select picked, or null for the default. + * @param model the model the select picked, or null for the default. + */ +public record RegenerateTurn(String connection, String model) { + + /** The pick this regenerate carries. */ + public ModelPick pick() { + return new ModelPick(connection, model); + } +} diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/TurnView.java b/backend/src/main/java/de/codeministry/leadgen/chat/TurnView.java index aeca53b..5757990 100644 --- a/backend/src/main/java/de/codeministry/leadgen/chat/TurnView.java +++ b/backend/src/main/java/de/codeministry/leadgen/chat/TurnView.java @@ -22,6 +22,8 @@ * @param sources the rows it cited, then its statistics calls, as {@link ChatSources} sent them. * @param replacesTurnId the turn a regenerate replaced, or null. * @param model which model answered. + * @param connection the connection it answered on, {@code PRIMARY} or {@code SECOND}; null for a + * turn stored before spec 025 (ISC-507). * @param createdAt when it started. * @param endReason why it ended incomplete, as its live {@code error} event said; null for every * other ending and for a turn stored before the reason was (spec 022, ISC-473). @@ -36,6 +38,36 @@ public record TurnView( List sources, Long replacesTurnId, String model, + String connection, Instant createdAt, ChatErrorReason endReason, - Instant finishedAt) {} + Instant finishedAt) { + + /** A turn whose connection is not read yet. */ + public TurnView( + long id, + String question, + String answer, + ChatTurnState state, + List steps, + List sources, + Long replacesTurnId, + String model, + Instant createdAt, + ChatErrorReason endReason, + Instant finishedAt) { + this( + id, + question, + answer, + state, + steps, + sources, + replacesTurnId, + model, + null, + createdAt, + endReason, + finishedAt); + } +} diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/catalog/AnthropicModels.java b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/AnthropicModels.java new file mode 100644 index 0000000..4b361e5 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/AnthropicModels.java @@ -0,0 +1,80 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat.catalog; + +import com.fasterxml.jackson.databind.JsonNode; +import de.codeministry.leadgen.config.model.PipelineConfig; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; + +/** + * The models the {@code anthropic} kind lists, paged through {@code has_more} and {@code last_id}. + * + *

Every listed model counts as tool-capable. This kind publishes no capability field, + * and every model it lists takes tools; {@code ChatModelCatalogTest} pins the assumption, so the + * day a listed model refuses a tool round it fails at the model call and ends the turn rather + * than being guessed around here. + * + *

The base URL is the host root, without {@code /v1}, the same convention {@code ChatModels} + * and {@code AnthropicJudge} follow; a value that already ends in {@code /v1} is not doubled. + */ +@RequiredArgsConstructor +final class AnthropicModels implements ChatModelCatalog.Lister { + + /** Pinned for the same reason as in {@code AnthropicJudge}: an unversioned request is refused. */ + private static final String API_VERSION = "2023-06-01"; + + /** The most a page may hold; the listing is short, so this is one page in practice. */ + private static final int PAGE_SIZE = 1000; + + /** A bound on the paging, so a server that always says {@code has_more} cannot hold the list forever. */ + private static final int MAX_PAGES = 20; + + private final ChatModelCatalog.Discovery discovery; + + @Override + public List list(PipelineConfig.Llm llm) throws IOException, InterruptedException { + String root = ChatModelCatalog.Discovery.trimSlash(llm.baseUrl()); + String models = (root.endsWith("/v1") ? root : root + "/v1") + "/models?limit=" + PAGE_SIZE; + // The URI first, so a malformed base URL reads as one whatever the key holds; then the + // header's precondition, enforced where it is built: a key that fits a header. + URI first = ChatModelCatalog.Discovery.uri(models); + String key = ChatModelCatalog.Discovery.key(llm.apiKey()); + List names = new ArrayList<>(); + String after = null; + for (int page = 0; page < MAX_PAGES; page++) { + URI url = after == null + ? first + : ChatModelCatalog.Discovery.uri( + models + "&after_id=" + URLEncoder.encode(after, StandardCharsets.UTF_8)); + JsonNode body = discovery.send(discovery + .request(url) + .header("x-api-key", key) + .header("anthropic-version", API_VERSION) + .GET() + .build()); + for (JsonNode model : body.path("data")) { + String id = model.path("id").asText(""); + if (!id.isBlank()) { + names.add(id); + } + } + after = body.path("last_id").asText(""); + if (!body.path("has_more").asBoolean(false) || after.isBlank()) { + break; + } + } + return names; + } +} diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ChatModelCatalog.java b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ChatModelCatalog.java new file mode 100644 index 0000000..ee2e208 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ChatModelCatalog.java @@ -0,0 +1,519 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat.catalog; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.codeministry.leadgen.config.ConfigRegistry; +import de.codeministry.leadgen.config.ConfigSnapshot; +import de.codeministry.leadgen.config.model.PipelineConfig; +import de.codeministry.leadgen.llm.ChatModels; +import de.codeministry.leadgen.llm.Connections; +import de.codeministry.leadgen.llm.Connections.Connection; +import de.codeministry.leadgen.llm.ModelChoice; +import de.codeministry.leadgen.llm.Providers; +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.net.http.HttpTimeoutException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * What the chat's model select offers: per configured connection, the models it reports that can + * call tools (spec 025, ISC-505). Read from the connection itself, never from a list in + * {@code .env}, so a model pulled or released yesterday is offered today. + * + *

A connection that does not answer never takes the other one down. Whatever goes wrong + * on one — a refused connection, a non-2xx, a redirect, no answer within {@link #DISCOVERY_TIMEOUT} + * — becomes that connection listed as unreachable, with one of a few fixed reasons and no models. + * A reason never names a host, a URL, a header or a response body, and neither does the server + * log: it gets the exception's class and the reason at WARN, never the exception's message or + * stack, because an HTTP client's refusal can quote a request header — the key with it. + * + *

A connection that could not answer a turn is not asked at all. {@link Providers#missing} + * is the rule the chat model is built by — every kind but Ollama needs a key, every kind a base + * URL — so a connection it finds lacking is listed unreachable without a request rather than + * offering models whose every turn would end with {@code MODEL}. + * + *

Cached per configuration snapshot for {@link #TTL}, and no lock is held across the + * network. {@link #models()} refreshes a stale list; concurrent callers that miss on the same + * snapshot wait on one discovery rather than each starting their own, and a reload hands out a new + * snapshot object, so the list read under the old one goes with it. The minute counts from when a + * discovery finished, so a slow one is served as long as a fast one. {@link #contains} reads the + * cached list and never fetches, because a turn must not wait on a model server's listing before + * it starts. + * + *

An interrupted discovery is nobody else's answer. Its connections read "interrupted" + * only because the caller running it was interrupted, so that caller alone gets that view; it is + * neither cached nor handed to the callers waiting on it, who ask again instead, under the + * configuration current by then. A waiter interrupted itself gets the same view, for itself only, + * and the discovery it waited on goes on. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatModelCatalog { + + /** How long a read list is served before {@link #models()} asks the connections again. */ + static final Duration TTL = Duration.ofSeconds(60); + + /** + * The discovery's own bound, per request, and deliberately not {@code llm.timeout}: that one is + * sized for a model thinking over a long prompt, and a listing that takes that long is a select + * that never opens. + */ + static final Duration DISCOVERY_TIMEOUT = Duration.ofSeconds(5); + + /** The reasons a connection is listed unreachable with; nothing else ever reaches the client. */ + static final String UNREACHABLE = "the connection could not be reached"; + + static final String TIMED_OUT = "the model list did not answer in time"; + static final String REDIRECTED = "the model list answered with a redirect"; + static final String UNREADABLE = "the model list could not be read"; + static final String NO_KEY = "no API key is configured for this connection"; + static final String NO_BASE_URL = "no base URL is configured for this connection"; + static final String INVALID_BASE_URL = "the base URL of this connection is not a valid URL"; + static final String INVALID_KEY = "the API key of this connection is not valid"; + static final String UNLISTABLE_KIND = "this provider kind cannot list its models"; + static final String INTERRUPTED = "the listing was interrupted"; + + private final ConfigRegistry config; + private final Discovery discovery = new Discovery(); + private final AtomicReference cached = new AtomicReference<>(); + + /** + * The discovery running per snapshot. Keyed by the snapshot object, so a caller still holding + * the one before a reload never displaces the discovery the new one is waiting on. + */ + private final ConcurrentMap> inFlight = new ConcurrentHashMap<>(); + + /** + * Every configured connection with its tool-capable models, from the cache while it is fresh. + * On a miss the caller either runs the one discovery for this snapshot or waits on the one + * already running; neither holds a lock while the connections are asked. + */ + public ChatModelsView models() { + while (true) { + // Read again on every round: a waiter asking again after an interrupted discovery lists + // the configuration current now, not the one before a reload. + ConfigSnapshot snapshot = config.snapshot(); + SnapshotKey key = new SnapshotKey(snapshot); + Cached current = cached.get(); + if (fresh(current, snapshot)) { + return current.view(); + } + CompletableFuture mine = new CompletableFuture<>(); + CompletableFuture running = inFlight.putIfAbsent(key, mine); + if (running == null) { + return discover(snapshot, key, mine); + } + Optional shared = await(running, snapshot); + if (shared.isPresent()) { + return shared.get(); + } + // The discovery waited on was interrupted, which says nothing about the connections. + } + } + + /** + * Whether the last list read under the current configuration offered {@code model} on + * {@code connection}. Never fetches: before any list was read, or after a reload, the answer + * is {@code false}. An aged list still answers, since the drawer offered exactly that list. + */ + public boolean contains(String connection, String model) { + Cached current = cached.get(); + if (current == null || current.snapshot() != config.snapshot() || connection == null || model == null) { + return false; + } + return current.view().connections().stream() + .filter(c -> c.name().equals(connection)) + .flatMap(c -> c.models().stream()) + .anyMatch(m -> m.name().equals(model)); + } + + private static boolean fresh(Cached current, ConfigSnapshot snapshot) { + return current != null + && current.snapshot() == snapshot + && System.nanoTime() - current.readAt() < TTL.toNanos(); + } + + /** + * Runs the discovery {@code mine} stands for, publishes it, and lets every waiter have it — unless + * it was interrupted, in which case only this caller gets it. The entry leaves {@link #inFlight} + * before the waiters are released, so one asking again starts a discovery of its own. + */ + private ChatModelsView discover(ConfigSnapshot snapshot, SnapshotKey key, CompletableFuture mine) { + ChatModelsView view; + boolean interrupted; + try { + // Another caller may have published this snapshot's list between the miss and the claim. + Cached current = cached.get(); + if (fresh(current, snapshot)) { + view = current.view(); + interrupted = false; + } else { + view = read(snapshot.application()); + interrupted = interrupted(view); + // A list read under a configuration that has since been reloaded is answered to its + // own callers and never published over the newer one's. Its minute starts now, when + // it was read, and not when the slowest connection was first asked. + if (!interrupted && config.snapshot() == snapshot) { + cached.set(new Cached(snapshot, System.nanoTime(), view)); + } + } + } catch (RuntimeException | Error e) { + inFlight.remove(key, mine); + mine.completeExceptionally(e); + throw e; + } + inFlight.remove(key, mine); + if (interrupted) { + mine.completeExceptionally(new DiscoveryInterrupted()); + } else { + mine.complete(view); + } + return view; + } + + /** Whether {@code view} reads unreachable only because the caller reading it was interrupted. */ + private static boolean interrupted(ChatModelsView view) { + return Thread.currentThread().isInterrupted() + || view.connections().stream().anyMatch(c -> INTERRUPTED.equals(c.reason())); + } + + /** + * The shared discovery's view, or empty when it was interrupted and the waiter should ask again. + * A waiter interrupted while it waits gets every connection of {@code snapshot} as interrupted, + * for itself alone: nothing is cached, and the discovery it waited on is left to finish. + */ + private static Optional await(CompletableFuture view, ConfigSnapshot snapshot) { + try { + return Optional.of(view.get()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return Optional.of(interruptedView(snapshot.application())); + } catch (ExecutionException e) { + if (e.getCause() instanceof DiscoveryInterrupted) { + return Optional.empty(); + } + if (e.getCause() instanceof RuntimeException cause) { + throw cause; + } + if (e.getCause() instanceof Error error) { + throw error; + } + throw new CompletionException(e.getCause()); + } + } + + /** Every connection of {@code pipeline} as unreachable because the caller was interrupted. */ + private static ChatModelsView interruptedView(PipelineConfig pipeline) { + List connections = Connections.all(pipeline).stream() + .map(connection -> unreachable(connection, INTERRUPTED)) + .toList(); + return new ChatModelsView(connections, defaultModel(pipeline).orElse(null)); + } + + private static Optional defaultModel(PipelineConfig pipeline) { + return ModelChoice.chat(pipeline.llm() == null ? null : pipeline.llm().models()); + } + + /** Both connections at once, so a slow one costs its own time and not the other's as well. */ + private ChatModelsView read(PipelineConfig pipeline) { + Optional defaultModel = defaultModel(pipeline); + List all = Connections.all(pipeline); + List connections = new ArrayList<>(all.size()); + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + List> reads = new ArrayList<>(all.size()); + for (Connection connection : all) { + Optional flagged = + connection.name() == Connections.Name.PRIMARY ? defaultModel : Optional.empty(); + reads.add(executor.submit(() -> read(connection, flagged))); + } + for (int i = 0; i < all.size(); i++) { + connections.add(collect(all.get(i), reads.get(i))); + } + } + return new ChatModelsView(List.copyOf(connections), defaultModel.orElse(null)); + } + + private static ConnectionView collect(Connection connection, Future read) { + try { + return read.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return unreachable(connection, INTERRUPTED); + } catch (ExecutionException e) { + // read(connection, …) catches everything it can name; this is a bug, not a connection. + // The class and the fixed reason only, as in read(): a message or a stack can quote a + // request header. + Throwable failure = e.getCause() == null ? e : e.getCause(); + log.warn( + "The {} connection ({}) lists no models for the chat: {} ({})", + connection.name(), + connection.kind(), + UNREADABLE, + failure.getClass().getName()); + return unreachable(connection, UNREADABLE); + } + } + + private ConnectionView read(Connection connection, Optional defaultModel) { + String name = connection.name().name(); + try { + // The chat model is built by the same rule, so nothing a lacking connection lists could + // answer. Checked before the lister is chosen, so a missing kind is worded here, once. + Optional missing = Providers.missing(connection.llm()); + if (missing.isPresent()) { + throw new ListingException( + switch (missing.get()) { + case PROVIDER -> UNLISTABLE_KIND; + case API_KEY -> NO_KEY; + case BASE_URL -> NO_BASE_URL; + }); + } + List models = lister(connection.kind()).list(connection.llm()).stream() + .distinct() + .map(model -> new ModelView(model, isDefault(connection.kind(), model, defaultModel))) + .toList(); + return new ConnectionView(name, connection.kind(), connection.billed(), true, null, models); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return unreachable(connection, INTERRUPTED); + } catch (IOException | RuntimeException e) { + String reason = reason(e); + // The class and the fixed reason only: a message or a stack can quote a request header. + log.warn( + "The {} connection ({}) lists no models for the chat: {} ({})", + name, + connection.kind(), + reason, + e.getClass().getName()); + return unreachable(connection, reason); + } + } + + /** The lister for {@code kind}, which {@link Providers#missing} has already found non-blank. */ + private Lister lister(String kind) throws ListingException { + return switch (kind) { + case ChatModels.OLLAMA -> new OllamaModels(discovery); + case ChatModels.ANTHROPIC -> new AnthropicModels(discovery); + case ChatModels.OPENAI_COMPATIBLE -> new OpenAiCompatibleModels(discovery); + default -> throw new ListingException(UNLISTABLE_KIND); + }; + } + + /** + * Whether {@code model} is the chat's default. Ollama reports a model with its tag, so a + * configured {@code name} without one is the same model as {@code name:latest}. + */ + private static boolean isDefault(String kind, String model, Optional defaultModel) { + if (defaultModel.isEmpty()) { + return false; + } + String wanted = defaultModel.get(); + return model.equals(wanted) || (ChatModels.OLLAMA.equals(kind) && model.equals(wanted + ":latest")); + } + + private static ConnectionView unreachable(Connection connection, String reason) { + return new ConnectionView( + connection.name().name(), connection.kind(), connection.billed(), false, reason, List.of()); + } + + /** + * Why a listing failed, as one of the fixed reasons above. Never an exception's own message: + * those name the host, the TLS peer, a request header or a snippet of the body the server + * answered with. A base URL or a key that cannot go into a request is a {@link ListingException} + * of its own, decided by {@link Discovery#uri} and {@link Discovery#key} before one is built. + */ + static String reason(Exception e) { + return switch (e) { + case ListingException listing -> listing.reason(); + case HttpTimeoutException _ -> TIMED_OUT; + case JacksonException _ -> UNREADABLE; + case IOException _ -> UNREACHABLE; + default -> UNREADABLE; + }; + } + + /** One list as read under one configuration snapshot, and when its discovery finished. */ + private record Cached(ConfigSnapshot snapshot, long readAt, ChatModelsView view) {} + + /** + * A snapshot by identity. {@link ConfigSnapshot} is a record, so its own {@code equals} would + * compare — and hash — the whole configuration on every call, and could take two reloads that + * read the same files for one. + */ + private record SnapshotKey(ConfigSnapshot snapshot) { + + @Override + public boolean equals(Object other) { + return other instanceof SnapshotKey key && key.snapshot == snapshot; + } + + @Override + public int hashCode() { + return System.identityHashCode(snapshot); + } + } + + /** A shared discovery was interrupted; its waiters ask again rather than take its view. */ + private static final class DiscoveryInterrupted extends RuntimeException { + + DiscoveryInterrupted() { + super(null, null, false, false); + } + } + + /** A listing that failed for a reason already worded for the client. */ + static final class ListingException extends IOException { + + private final String reason; + + ListingException(String reason) { + super(reason); + this.reason = reason; + } + + String reason() { + return reason; + } + } + + /** One provider kind's way of listing the models a chat may pick on a connection. */ + interface Lister { + + /** The tool-capable models {@code llm} reports, in its order; any failure is thrown. */ + List list(PipelineConfig.Llm llm) throws IOException, InterruptedException; + } + + /** + * The HTTP clients every adapter shares, bounded by {@link #DISCOVERY_TIMEOUT}. + * + *

A request carrying a credential never follows a redirect: a 3xx would carry the + * connection's key to wherever the {@code Location} points. A request with nothing but + * {@link #CREDENTIAL_FREE} headers — Ollama's, which needs no key — follows one, so a proxy + * moving a local server from http to https does not make it unreachable. The choice is made by + * the headers a request actually carries, so a new header is kept from redirects until it is + * named here. + */ + static final class Discovery { + + /** The message a keyed listing called without a key fails with; it never quotes the value. */ + static final String KEY_REQUIRED = "a keyed listing needs a key; Providers.missing refuses a blank one first"; + + /** The headers a request may carry and still follow a redirect, lower-cased. */ + static final Set CREDENTIAL_FREE = Set.of("accept", "content-type"); + + private final HttpClient keyed = HttpClient.newBuilder() + .connectTimeout(DISCOVERY_TIMEOUT) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + private final HttpClient open = HttpClient.newBuilder() + .connectTimeout(DISCOVERY_TIMEOUT) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + private final ObjectMapper json = new ObjectMapper(); + + HttpRequest.Builder request(URI uri) { + return HttpRequest.newBuilder(uri).timeout(DISCOVERY_TIMEOUT).header("Accept", "application/json"); + } + + /** + * {@code url} as a URI a request can be built on, or {@link #INVALID_BASE_URL}: it must parse, + * speak http or https, and name a host. The URI is checked on its own, before any header is + * set, so a bad key can never be mistaken for a bad base URL. + */ + static URI uri(String url) throws ListingException { + try { + URI uri = URI.create(url); + String scheme = uri.getScheme(); + if (scheme == null + || !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https")) + || uri.getHost() == null) { + throw new ListingException(INVALID_BASE_URL); + } + return uri; + } catch (IllegalArgumentException e) { + throw new ListingException(INVALID_BASE_URL); + } + } + + /** + * {@code apiKey} as a credential header's value, or {@link #INVALID_KEY} when it holds anything + * but printable ASCII. Checked before the header is set, so the HTTP client never refuses a + * value — a refusal that would quote the whole key. + * + *

Whether a key is there at all is {@link Providers#missing}'s question, asked before any + * lister is chosen, and worded {@link #NO_KEY} there. A blank key reaching this method is a + * caller that skipped it, so it is refused with {@link #KEY_REQUIRED} — a fixed message — and + * a request never carries {@code Bearer null} or an empty key. + */ + static String key(String apiKey) throws ListingException { + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalArgumentException(KEY_REQUIRED); + } + for (int i = 0; i < apiKey.length(); i++) { + char c = apiKey.charAt(i); + if (c < 0x20 || c > 0x7E) { + throw new ListingException(INVALID_KEY); + } + } + return apiKey; + } + + /** The body as JSON, or a {@link ListingException} for a redirect or any other non-2xx. */ + JsonNode send(HttpRequest request) throws IOException, InterruptedException { + HttpClient http = carriesNoCredential(request) ? open : keyed; + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + int status = response.statusCode(); + if (status / 100 == 3) { + throw new ListingException(REDIRECTED); + } + if (status / 100 != 2) { + throw new ListingException("the model list answered HTTP " + status); + } + return json.readTree(response.body()); + } + + private static boolean carriesNoCredential(HttpRequest request) { + return request.headers().map().keySet().stream() + .allMatch(name -> CREDENTIAL_FREE.contains(name.toLowerCase(Locale.ROOT))); + } + + /** {@code url} without its trailing slashes; blank stays blank. */ + static String trimSlash(String url) { + String trimmed = url == null ? "" : url.trim(); + while (trimmed.endsWith("/")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } + } +} diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ChatModelsView.java b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ChatModelsView.java new file mode 100644 index 0000000..1ba2a73 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ChatModelsView.java @@ -0,0 +1,25 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat.catalog; + +import java.util.List; + +/** + * {@code GET /api/v1/chat/models}: what the chat's model select offers, read from each configured + * connection rather than from a list in {@code .env} (spec 025, ISC-505). A turn may name only a + * model this answer listed (ISC-506). + * + * @param connections the primary connection first, then the second one when it is configured. + * @param defaultModelName the name of the model a turn without a pick takes — {@code llm.models.chat}, + * else {@code scoring}, on the primary connection — verbatim as configured, + * whether or not the primary listed it under that exact name; {@code null} + * when no chat model is configured. Not {@link ModelView#defaultModel}, which + * flags one listed model. + */ +public record ChatModelsView(List connections, String defaultModelName) {} diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ConnectionView.java b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ConnectionView.java new file mode 100644 index 0000000..9baa85c --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ConnectionView.java @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat.catalog; + +import java.util.List; + +/** + * One connection in {@link ChatModelsView}. + * + * @param name {@code PRIMARY} or {@code SECOND}, the name a turn and a stored row carry; never + * the base URL. + * @param kind the provider kind the connection speaks. + * @param billed whether a call on it is paid for, as {@code Connections} resolves it. + * @param reachable whether the connection answered the model list; {@code false} lists no models. + * @param reason why the connection could not be listed, one of a few fixed sentences that never + * name a host, URL, header or body; {@code null} when it was. A spent daily ceiling + * is not a reason here: it is read from {@code GET /chat/status} + * ({@code secondCalls}, {@code secondLimit}). + * @param models the models it reported that can call tools. + */ +public record ConnectionView( + String name, String kind, boolean billed, boolean reachable, String reason, List models) {} diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ModelView.java b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ModelView.java new file mode 100644 index 0000000..4937f56 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ModelView.java @@ -0,0 +1,18 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat.catalog; + +/** + * One model a connection offers the chat. + * + * @param name the model's name as the connection reported it. + * @param defaultModel whether it is the model a turn without a pick takes: {@code llm.models.chat}, + * else {@code scoring}, on the primary connection. + */ +public record ModelView(String name, boolean defaultModel) {} diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/catalog/OllamaModels.java b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/OllamaModels.java new file mode 100644 index 0000000..56def40 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/OllamaModels.java @@ -0,0 +1,139 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat.catalog; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import de.codeministry.leadgen.config.model.PipelineConfig; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * The models an Ollama server holds that can call tools, read from its native API. + * + *

The chat talks to Ollama on its OpenAI-compatible path, so the configured base URL ends in + * {@code /v1}; the native API lives at the root beside it. {@code /api/tags} lists what is + * pulled, and {@code /api/show} is asked per model, at most {@link #SHOW_PARALLELISM} at a time, + * because only it names the capabilities: a model without {@code tools} among them would refuse + * the chat's first tool round, so it is not offered at all. + */ +@Slf4j +@RequiredArgsConstructor +final class OllamaModels implements ChatModelCatalog.Lister { + + /** How many {@code /api/show} requests run at once. */ + static final int SHOW_PARALLELISM = 4; + + private final ChatModelCatalog.Discovery discovery; + + @Override + public List list(PipelineConfig.Llm llm) throws IOException, InterruptedException { + String root = nativeRoot(llm.baseUrl()); + URI showUri = ChatModelCatalog.Discovery.uri(root + "/api/show"); + JsonNode tags = discovery.send(discovery + .request(ChatModelCatalog.Discovery.uri(root + "/api/tags")) + .GET() + .build()); + List names = new ArrayList<>(); + for (JsonNode entry : tags.path("models")) { + String name = entry.path("name").asText(""); + if (!name.isBlank()) { + names.add(name); + } + } + if (names.isEmpty()) { + return List.of(); + } + // A few descriptions at a time: one after the other, forty pulled models are forty round + // trips before the select opens; all at once, they are forty requests against one local server. + ExecutorService executor = Executors.newFixedThreadPool( + Math.min(SHOW_PARALLELISM, names.size()), Thread.ofVirtual().factory()); + try { + List> described = new ArrayList<>(names.size()); + for (String name : names) { + described.add(executor.submit(() -> callsTools(showUri, name))); + } + List capable = new ArrayList<>(); + for (int i = 0; i < names.size(); i++) { + if (answer(names.get(i), described.get(i))) { + capable.add(names.get(i)); + } + } + return capable; + } finally { + // Every description is in on the way out; on an interrupt or an Error, the queued ones never start. + executor.shutdownNow(); + } + } + + /** + * The answer of one description. A failure the task did not catch itself skips that one model + * with a WARN as well — the list it came from did answer. An {@link Error} is not a model. + */ + private static boolean answer(String name, Future described) throws InterruptedException { + try { + return described.get(); + } catch (ExecutionException e) { + if (e.getCause() instanceof Error error) { + throw error; + } + // The class only: a message or a stack can quote a request header. + Throwable failure = e.getCause() == null ? e : e.getCause(); + log.warn( + "Ollama could not describe model '{}', so it is not offered to the chat ({})", + name, + failure.getClass().getName()); + return false; + } + } + + /** + * Whether {@code /api/show} lists {@code tools} for {@code name}. One model the server cannot + * describe — whatever went wrong, checked or not — is left out with a WARN rather than taking the + * whole connection down: the list it came from did answer. + */ + private boolean callsTools(URI showUri, String name) throws InterruptedException { + String body = JsonNodeFactory.instance.objectNode().put("model", name).toString(); + try { + JsonNode show = discovery.send(discovery + .request(showUri) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build()); + for (JsonNode capability : show.path("capabilities")) { + if ("tools".equals(capability.asText())) { + return true; + } + } + return false; + } catch (IOException | RuntimeException e) { + // The class only: a message or a stack can quote a request header. + log.warn( + "Ollama could not describe model '{}', so it is not offered to the chat ({})", + name, + e.getClass().getName()); + return false; + } + } + + /** {@code base_url} without its trailing slash and its trailing {@code /v1}, in that order. */ + static String nativeRoot(String baseUrl) { + String root = ChatModelCatalog.Discovery.trimSlash(baseUrl); + return root.endsWith("/v1") ? root.substring(0, root.length() - "/v1".length()) : root; + } +} diff --git a/backend/src/main/java/de/codeministry/leadgen/chat/catalog/OpenAiCompatibleModels.java b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/OpenAiCompatibleModels.java new file mode 100644 index 0000000..9e93f91 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/OpenAiCompatibleModels.java @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat.catalog; + +import com.fasterxml.jackson.databind.JsonNode; +import de.codeministry.leadgen.config.model.PipelineConfig; +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; + +/** + * The models an {@code openai-compatible} endpoint lists at {@code models}, relative to its base. + * + *

The base URL already carries the API's version segment ({@code …/v1}): the chat model is + * built on it and appends {@code /chat/completions}, so the list sits beside that path. + * + *

Every listed model counts as tool-capable, because the wire format carries no + * capability field. A model that turns out not to take tools fails at the model call and ends the + * turn; nothing here guesses from its name. + */ +@RequiredArgsConstructor +final class OpenAiCompatibleModels implements ChatModelCatalog.Lister { + + private final ChatModelCatalog.Discovery discovery; + + @Override + public List list(PipelineConfig.Llm llm) throws IOException, InterruptedException { + // The URI first, so a malformed base URL reads as one whatever the key holds; then the + // header's precondition, enforced where it is built: a key that fits a header. + URI models = ChatModelCatalog.Discovery.uri(ChatModelCatalog.Discovery.trimSlash(llm.baseUrl()) + "/models"); + String key = ChatModelCatalog.Discovery.key(llm.apiKey()); + JsonNode body = discovery.send(discovery + .request(models) + .header("Authorization", "Bearer " + key) + .GET() + .build()); + List names = new ArrayList<>(); + for (JsonNode model : body.path("data")) { + String id = model.path("id").asText(""); + if (!id.isBlank()) { + names.add(id); + } + } + return names; + } +} diff --git a/backend/src/main/java/de/codeministry/leadgen/config/ConfigLoader.java b/backend/src/main/java/de/codeministry/leadgen/config/ConfigLoader.java index 4018d8a..1a42839 100644 --- a/backend/src/main/java/de/codeministry/leadgen/config/ConfigLoader.java +++ b/backend/src/main/java/de/codeministry/leadgen/config/ConfigLoader.java @@ -19,6 +19,7 @@ import de.codeministry.leadgen.config.model.SkillProfile; import de.codeministry.leadgen.config.model.SourcesConfig; import de.codeministry.leadgen.filter.TextFold; +import de.codeministry.leadgen.llm.ChatModels; import de.codeministry.leadgen.security.SecurityConfig; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validator; @@ -373,6 +374,10 @@ private void checkConsistency( "llm.batch is true and llm.provider is '%s'; only '%s' has a batch endpoint implemented, and any other provider would score synchronously at full price while the flag says otherwise" .formatted(llm.provider(), PipelineConfig.Llm.BATCHING_PROVIDER)); } + if (llm != null) { + checkBilled("llm.billed", llm.billed(), problems); + } + checkChatConnection(pipeline.chat() == null ? null : pipeline.chat().connection(), problems); // A worker holds a connection while it writes its advert back. Wider than the pool, the // extra workers wait for one and give up after Hikari's 30 s, which reads like a broken @@ -464,6 +469,54 @@ private void checkConsistency( } } + /** + * The chat's second connection, {@code chat.connection}. Absent, or present with an empty + * {@code provider}, is no second connection and nothing to check: the chat runs on {@code llm} + * alone, as before the block existed. + * + *

{@code provider} is a kind, never a default, so a kind nothing implements is refused by + * name rather than built into a request of the wrong shape. A hosted kind needs a base URL, + * because a URL in the code would be a vendor in the code; Ollama needs no key, which is a + * rule about the value and therefore not checked here at all. + */ + private static void checkChatConnection(PipelineConfig.ChatConnection connection, List problems) { + if (connection == null || !connection.configured()) { + return; + } + String kind = connection.provider(); + if (!CHAT_CONNECTION_KINDS.contains(kind)) { + problems.add( + "chat.connection.provider is '%s'; implemented are %s — any other kind would be read and then build no model, leaving a second connection that looks configured and never answers" + .formatted(kind, String.join(", ", CHAT_CONNECTION_KINDS))); + } else if (!ChatModels.OLLAMA.equals(kind) + && (connection.baseUrl() == null || connection.baseUrl().isBlank())) { + problems.add( + "chat.connection.base_url is empty and chat.connection.provider is '%s'; a hosted kind needs its base URL set in .env (CHAT_LLM_BASE_URL), because no address is written into the code" + .formatted(kind)); + } + checkBilled("chat.connection.billed", connection.billed(), problems); + } + + /** The kinds a second connection may name: the ones {@code ChatModels.of} builds a model for. */ + private static final List CHAT_CONNECTION_KINDS = + List.of(ChatModels.ANTHROPIC, ChatModels.OLLAMA, ChatModels.OPENAI_COMPATIBLE); + + /** + * A {@code billed} key is {@code true}, {@code false} or empty. Anything else — a "yes", a + * typo — would otherwise fall to the kind's default in silence, and a paid endpoint written + * as "ture" would be shown as free. + */ + private static void checkBilled(String key, String billed, List problems) { + if (billed == null || billed.isBlank()) { + return; + } + String value = billed.strip(); + if (!"true".equalsIgnoreCase(value) && !"false".equalsIgnoreCase(value)) { + problems.add( + "%s is '%s'; it is true, false, or empty to decide by the provider kind".formatted(key, value)); + } + } + private void checkWidth(String key, int width, List problems) { if (width > connectionPoolSize) { problems.add( diff --git a/backend/src/main/java/de/codeministry/leadgen/config/ConfigProperties.java b/backend/src/main/java/de/codeministry/leadgen/config/ConfigProperties.java index 6d41c06..a612377 100644 --- a/backend/src/main/java/de/codeministry/leadgen/config/ConfigProperties.java +++ b/backend/src/main/java/de/codeministry/leadgen/config/ConfigProperties.java @@ -57,7 +57,7 @@ public record ConfigProperties( // One home per default, read by the bound constructor and the hand-built one alike, so // the two cannot drift. `@DefaultValue` takes a compile-time constant, hence strings. - private static final String DEFAULT_VERSION = "0.5.4"; + private static final String DEFAULT_VERSION = "0.5.5"; private static final String DEFAULT_CONFIG_POLL_INTERVAL = "PT2S"; private static final String DEFAULT_SCORE_BATCH_POLL_INTERVAL = "PT5M"; private static final String DEFAULT_INGEST_CRON = "-"; diff --git a/backend/src/main/java/de/codeministry/leadgen/config/model/PipelineConfig.java b/backend/src/main/java/de/codeministry/leadgen/config/model/PipelineConfig.java index b888bba..25e004c 100644 --- a/backend/src/main/java/de/codeministry/leadgen/config/model/PipelineConfig.java +++ b/backend/src/main/java/de/codeministry/leadgen/config/model/PipelineConfig.java @@ -89,7 +89,8 @@ public record Llm( boolean batch, @NotNull Models models, @Valid Budget budget, - @Min(1) Integer concurrency) { + @Min(1) Integer concurrency, + String billed) { /** * Every component but the width, which is then absent and reads as {@code 1}. For code @@ -104,7 +105,23 @@ public Llm( boolean batch, Models models, Budget budget) { - this(provider, baseUrl, apiKey, timeout, batch, models, budget, null); + this(provider, baseUrl, apiKey, timeout, batch, models, budget, null, null); + } + + /** + * Every component but {@code billed}, which is then absent and resolved from the provider + * kind ({@code Connections.billed}). For code that has nothing to say about billing. + */ + public Llm( + String provider, + String baseUrl, + String apiKey, + Duration timeout, + boolean batch, + Models models, + Budget budget, + Integer concurrency) { + this(provider, baseUrl, apiKey, timeout, batch, models, budget, concurrency, null); } /** @@ -446,15 +463,60 @@ public record Security(@NotBlank String auth, Map oidc) {} * shipped default of 6 ({@code ChatBudget.DEFAULT_TOOL_ROUNDS}), never no bound. * @param suggestions the lines the empty chat's suggestion triggers read; absent is every * shipped default ({@code SuggestionThresholds}). + * @param connection the chat's optional second connection; absent, or present with an empty + * {@code provider}, means the chat runs on {@code llm} alone, as before. */ public record Chat( @Min(0) Integer maxCallsPerDay, @Min(1) Integer maxToolRounds, - @Valid Suggestions suggestions) { + @Valid Suggestions suggestions, + @Valid ChatConnection connection) { /** The ceilings alone, for code that has nothing to say about suggestions. */ public Chat(Integer maxCallsPerDay, Integer maxToolRounds) { - this(maxCallsPerDay, maxToolRounds, null); + this(maxCallsPerDay, maxToolRounds, null, null); + } + + /** Every component but the second connection, which is then absent. */ + public Chat(Integer maxCallsPerDay, Integer maxToolRounds, Suggestions suggestions) { + this(maxCallsPerDay, maxToolRounds, suggestions, null); + } + } + + /** + * A second connection only the chat reads, beside {@code llm} and never in its place: every + * pipeline stage keeps the one provider, and the chat may additionally offer the models of this + * one. {@code Connections} maps it onto the {@link Llm} shape {@code ChatModels.of} takes. + * + *

Every value is optional in the record and checked by {@code ConfigLoader}: an empty + * {@code provider} is the block being absent, an unknown kind is refused, and a hosted kind + * without a {@code baseUrl} is refused, because a URL in the code would be a vendor in the code. + * + * @param provider the wire format, one of the kinds {@code ChatModels} implements; never a default + * @param baseUrl where the requests go; required for a hosted kind + * @param apiKey the key a hosted kind needs; Ollama needs none + * @param billed whether a call here costs money, bound as text so that absent stays + * distinguishable from false; absent is resolved from the kind by + * {@code Connections.billed} + * @param timeout how long one request may take; absent is {@link Llm#DEFAULT_TIMEOUT} + * @param maxCallsPerDay this connection's own daily ceiling, counted beside the chat's + */ + public record ChatConnection( + String provider, + String baseUrl, + String apiKey, + String billed, + Duration timeout, + @Min(0) Integer maxCallsPerDay) { + + @Override + public Duration timeout() { + return timeout == null ? Llm.DEFAULT_TIMEOUT : timeout; + } + + /** True when the block names a provider kind; anything else is the block being absent. */ + public boolean configured() { + return provider != null && !provider.isBlank(); } } diff --git a/backend/src/main/java/de/codeministry/leadgen/llm/Connections.java b/backend/src/main/java/de/codeministry/leadgen/llm/Connections.java new file mode 100644 index 0000000..621c555 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/llm/Connections.java @@ -0,0 +1,113 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.llm; + +import de.codeministry.leadgen.config.model.PipelineConfig; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +/** + * The connections the chat may ask a model on: {@code llm}, which every stage uses, and the + * optional {@code chat.connection} beside it. + * + *

Each one is mapped onto the {@link PipelineConfig.Llm} shape {@link ChatModels#of} already + * takes, so building a model on the second connection is the same call as on the first, and the + * rules about keys and base URLs in {@code Providers} hold for both without a second copy. + * + *

Whether a connection is billed is decided here and nowhere else. An explicit + * {@code billed: true} or {@code false} wins; absent means billed for the hosted kind only, + * because an {@code openai-compatible} endpoint may be a local server or a paid one and only + * the operator knows which. + */ +public final class Connections { + + /** The two names a connection goes by; stored on a turn, never its base URL. */ + public enum Name { + PRIMARY, + SECOND + } + + /** + * One connection, ready for {@link ChatModels#of}. + * + * @param name which of the two it is + * @param kind the provider kind, as configured + * @param llm the connection in the shape every model factory takes + * @param billed whether a call on it costs money, resolved by {@link #billed} + */ + public record Connection(Name name, String kind, PipelineConfig.Llm llm, boolean billed) {} + + private Connections() {} + + /** The primary connection, {@code llm}, whatever it holds. */ + public static Connection primary(PipelineConfig pipeline) { + PipelineConfig.Llm llm = pipeline.llm(); + return new Connection(Name.PRIMARY, llm.provider(), llm, billed(llm.provider(), llm.billed())); + } + + /** + * The second connection, present only when {@code chat.connection} names a provider kind. + * + *

It borrows the primary's {@code models} block because the shape requires one; nothing + * on this connection reads a model from it, since the chat names the model per turn. No + * {@code budget} and no {@code concurrency}: the second connection is metered by the chat's + * ceilings and never by {@code llm.budget}. + */ + public static Optional second(PipelineConfig pipeline) { + PipelineConfig.ChatConnection block = + pipeline.chat() == null ? null : pipeline.chat().connection(); + if (block == null || !block.configured()) { + return Optional.empty(); + } + PipelineConfig.Llm llm = new PipelineConfig.Llm( + block.provider(), + block.baseUrl(), + block.apiKey(), + block.timeout(), + false, + pipeline.llm().models(), + null, + null, + block.billed()); + return Optional.of( + new Connection(Name.SECOND, block.provider(), llm, billed(block.provider(), block.billed()))); + } + + /** The connection going by {@code name}, when it is configured. */ + public static Optional of(PipelineConfig pipeline, Name name) { + return name == Name.PRIMARY ? Optional.of(primary(pipeline)) : second(pipeline); + } + + /** Both connections, the primary first and the second only when configured. */ + public static List all(PipelineConfig pipeline) { + List all = new ArrayList<>(2); + all.add(primary(pipeline)); + second(pipeline).ifPresent(all::add); + return List.copyOf(all); + } + + /** + * Whether a connection of {@code kind} is billed, given what its {@code billed} key says. + * + *

An explicit {@code true} or {@code false} wins, in any case. Anything else — absent, + * empty — falls to the kind: billed for {@link ChatModels#ANTHROPIC}, not billed for every + * other kind. {@code ConfigLoader} refuses a value that is neither, so a typo never reads as + * "not billed" here. + */ + public static boolean billed(String kind, String billed) { + String value = billed == null ? "" : billed.trim().toLowerCase(Locale.ROOT); + return switch (value) { + case "true" -> true; + case "false" -> false; + default -> ChatModels.ANTHROPIC.equals(kind); + }; + } +} diff --git a/backend/src/main/java/de/codeministry/leadgen/llm/Providers.java b/backend/src/main/java/de/codeministry/leadgen/llm/Providers.java index 344a542..9b6710d 100644 --- a/backend/src/main/java/de/codeministry/leadgen/llm/Providers.java +++ b/backend/src/main/java/de/codeministry/leadgen/llm/Providers.java @@ -9,20 +9,32 @@ package de.codeministry.leadgen.llm; import de.codeministry.leadgen.config.model.PipelineConfig; +import java.util.Optional; import lombok.extern.slf4j.Slf4j; /** * Whether a configuration can reach a model at all, asked the same way for every kind of * model there is. * - *

It is one method because the answer is three rules that were each learned once, and a + *

It is one class because the answer is three rules that were each learned once, and a * second copy of them would relearn them: a local provider needs no key, a base URL is * required even for a vendor whose address never changes, and every refusal is a quiet * {@code false} or a WARN rather than an exception — a missing key must leave the tool - * running, only weaker. + * running, only weaker. {@link #missing} states the rules once; {@link #reachable} answers a + * stage with them, and the chat's model catalog words them for its select. */ @Slf4j -final class Providers { +public final class Providers { + + /** What a connection lacks before any model on it is worth trying, in the order it is checked. */ + public enum Missing { + /** No provider kind is configured. */ + PROVIDER, + /** A hosted provider without a key; a local one ({@link ChatModels#OLLAMA}) needs none. */ + API_KEY, + /** No base URL, which is required even for a hosted provider whose address never changes. */ + BASE_URL + } private Providers() {} @@ -33,22 +45,39 @@ private Providers() {} * chat and embeddings do not support the same ones. */ static boolean reachable(PipelineConfig.Llm llm, String model) { - if (llm == null || blank(llm.provider()) || blank(model)) { + if (llm == null || blank(model)) { return false; } + Optional missing = missing(llm); + if (missing.isEmpty()) { + return true; + } + if (missing.get() == Missing.BASE_URL) { + log.warn("llm.base_url is not set; there is nowhere to send a request"); + } + return false; + } + + /** + * The first thing {@code llm} lacks before any model on it could answer, or empty when it lacks + * nothing. Says nothing about a model name, and never logs: what a gap means is the caller's. + */ + public static Optional missing(PipelineConfig.Llm llm) { + if (llm == null || blank(llm.provider())) { + return Optional.of(Missing.PROVIDER); + } // A key is what a hosted provider needs and a local one does not. Requiring it // everywhere made `provider: ollama` unusable: a local server wants no key, so there // was nothing to write in `.env`, and nothing was ever built. if (blank(llm.apiKey()) && !ChatModels.OLLAMA.equals(llm.provider())) { - return false; + return Optional.of(Missing.API_KEY); } + // Required even for a hosted provider whose address never changes: a URL in the + // code is a vendor in the code, and this repository has none. if (blank(llm.baseUrl())) { - // Required even for a hosted provider whose address never changes: a URL in the - // code is a vendor in the code, and this repository has none. - log.warn("llm.base_url is not set; there is nowhere to send a request"); - return false; + return Optional.of(Missing.BASE_URL); } - return true; + return Optional.empty(); } static boolean blank(String value) { diff --git a/backend/src/main/java/de/codeministry/leadgen/web/ChatController.java b/backend/src/main/java/de/codeministry/leadgen/web/ChatController.java index 0236c5d..6632fcf 100644 --- a/backend/src/main/java/de/codeministry/leadgen/web/ChatController.java +++ b/backend/src/main/java/de/codeministry/leadgen/web/ChatController.java @@ -21,22 +21,28 @@ import de.codeministry.leadgen.chat.ConversationRepository; import de.codeministry.leadgen.chat.ConversationSummary; import de.codeministry.leadgen.chat.ConversationView; +import de.codeministry.leadgen.chat.ModelPick; import de.codeministry.leadgen.chat.NewContext; import de.codeministry.leadgen.chat.NewConversation; import de.codeministry.leadgen.chat.NewTurn; +import de.codeministry.leadgen.chat.RegenerateTurn; import de.codeministry.leadgen.chat.RenameConversation; +import de.codeministry.leadgen.chat.catalog.ChatModelCatalog; +import de.codeministry.leadgen.chat.catalog.ChatModelsView; import de.codeministry.leadgen.chat.suggest.FollowUp; import de.codeministry.leadgen.chat.suggest.FollowUps; import de.codeministry.leadgen.chat.suggest.Suggestion; import de.codeministry.leadgen.chat.suggest.SuggestionScope; import de.codeministry.leadgen.chat.suggest.SuggestionService; import de.codeministry.leadgen.config.ConfigProperties; +import de.codeministry.leadgen.llm.Connections; import jakarta.annotation.PreDestroy; import jakarta.validation.Valid; import java.io.IOException; import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.OptionalInt; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; @@ -101,6 +107,9 @@ class ChatController { private final FollowUps followUps; private final ChatBudget budget; + /** The model select's list, read from each configured connection (ISC-505). */ + private final ChatModelCatalog catalog; + /** * {@code leadgen.chat.*}: the turn timeout {@link ChatTurnService} stops a turn at, read here * for the stream's own timeout, and how often a running turn's stream carries a comment. @@ -125,7 +134,29 @@ ChatStatusView status() { String model = capability .model() .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no chat model is configured")); - return new ChatStatusView(model, budget.used(), budget.limit(), budget.toolRounds()); + // The second connection's counter and ceiling only where one is configured (ISC-510). + OptionalInt second = budget.secondLimit(); + return new ChatStatusView( + model, + budget.used(), + budget.limit(), + budget.toolRounds(), + second.isPresent() ? budget.used(Connections.Name.SECOND) : null, + second.isPresent() ? second.getAsInt() : null); + } + + /** + * ISC-505: per configured connection, the models it reports that can call tools, with its kind, + * whether it is billed and the default flagged; a connection that does not answer is listed + * with its reason. Absent where no chat model is configured, like {@link #status()}, and then + * no connection is asked. + */ + @GetMapping("/models") + ChatModelsView models() { + if (capability.model().isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no chat model is configured"); + } + return catalog.models(); } /** @@ -285,10 +316,11 @@ ResponseEntity ask(@PathVariable long id, @Valid @RequestBody NewTur if (capability.model().isEmpty()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "no chat model is configured"); } + ModelPick pick = discovered(body.pick()); SseEmitter emitter = emitter(); Runnable quiet = keepAlive(emitter); try { - turns.start(id, body.question(), sink(emitter), ended(emitter, quiet)); + turns.start(id, body.question(), pick, sink(emitter), ended(emitter, quiet)); } catch (RejectedExecutionException e) { quiet.run(); throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "too many turns are running"); @@ -380,14 +412,16 @@ void stop(@PathVariable long id, @PathVariable long turnId) { /** The same question again, as a new turn that records the one it replaces. */ @PostMapping(path = "/conversations/{id}/turns/{turnId}/regenerate", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - ResponseEntity regenerate(@PathVariable long id, @PathVariable long turnId) { + ResponseEntity regenerate( + @PathVariable long id, @PathVariable long turnId, @RequestBody(required = false) RegenerateTurn body) { if (capability.model().isEmpty()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "no chat model is configured"); } + ModelPick pick = discovered(body == null ? ModelPick.DEFAULT : body.pick()); SseEmitter emitter = emitter(); Runnable quiet = keepAlive(emitter); try { - if (!turns.regenerate(id, turnId, sink(emitter), ended(emitter, quiet))) { + if (!turns.regenerate(id, turnId, pick, sink(emitter), ended(emitter, quiet))) { quiet.run(); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no turn " + turnId + " in " + id); } @@ -398,6 +432,39 @@ ResponseEntity regenerate(@PathVariable long id, @PathVariable long return unbuffered(emitter); } + /** + * {@code pick} when the server discovered it itself, else a 400 before any turn exists — so no + * row is written, no counter taken and no model called (spec 025, ISC-506). The default is never + * checked: it is the configured chat model, not a name the client chose. + * + *

The cached list answers first. {@link ChatModelCatalog#contains} is false whenever no list + * was read under the current configuration — right after a start or a reload, before the drawer + * asked for one — so a miss reads the list once through {@link ChatModelCatalog#models()}, which + * serves a fresh cache without a request, and decides on what that list holds. A name the + * connections do not report is refused either way. + */ + private ModelPick discovered(ModelPick pick) { + if (pick.isDefault()) { + return ModelPick.DEFAULT; + } + if (pick.isPartial()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a pick names both a connection and a model"); + } + if (catalog.contains(pick.connection(), pick.model()) || listed(catalog.models(), pick)) { + return pick; + } + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "model '" + pick.model() + "' is not one the " + pick.connection() + " connection listed"); + } + + private static boolean listed(ChatModelsView models, ModelPick pick) { + return models.connections().stream() + .filter(connection -> connection.name().equals(pick.connection())) + .flatMap(connection -> connection.models().stream()) + .anyMatch(model -> model.name().equals(pick.model())); + } + private static ResponseStatusException notFound(long id) { return new ResponseStatusException(HttpStatus.NOT_FOUND, "no conversation " + id); } diff --git a/backend/src/main/java/de/codeministry/leadgen/workflow/WorkflowCatalog.java b/backend/src/main/java/de/codeministry/leadgen/workflow/WorkflowCatalog.java index 23b29fd..72cca2e 100644 --- a/backend/src/main/java/de/codeministry/leadgen/workflow/WorkflowCatalog.java +++ b/backend/src/main/java/de/codeministry/leadgen/workflow/WorkflowCatalog.java @@ -471,6 +471,10 @@ public static Optional ownerOf(String file, String leaf) { pipeline("chat.suggestions.tag_window_days"), pipeline("chat.suggestions.tag_rise_percent"), pipeline("chat.suggestions.tag_rise_min_offers"), + // The chat's second connection and the billed marker (spec 025): read by the chat's model + // select and its turns, never by a stage — the pipeline runs on `llm` alone. + pipeline("llm.billed"), + pipeline("chat.connection"), // skill-profile.yaml profile("version"), profile("core[].since"), diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 76bf640..0ea01d3 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -25,7 +25,7 @@ spring: name: leadgen # What a client reads in the `initialize` result: the running build, from the one place # the build names itself (leadgen.version below). - version: ${LEADGEN_VERSION:0.5.4} + version: ${LEADGEN_VERSION:0.5.5} # STREAMABLE and not SSE: Spring AI 2.0 deprecates SSE, and Streamable HTTP is what # current MCP clients negotiate. protocol: STREAMABLE @@ -43,10 +43,10 @@ spring: # not have to exist — without it the defaults apply. leadgen: # What the header shows, and the only place the running build names itself. Without this - # line the default on ConfigProperties applies and every image reports 0.5.4 — + # line the default on ConfigProperties applies and every image reports 0.5.5 — # the Gradle version is not injected into the jar, and the Dockerfile copies libs/*.jar # by glob, so nothing else carries it either. The deploy sets it from the image tag. - version: ${LEADGEN_VERSION:0.5.4} + version: ${LEADGEN_VERSION:0.5.5} config-dir: ${LEADGEN_CONFIG_DIR:config} security: # Asserts that something outside this process decides who reaches the port, which is diff --git a/backend/src/main/resources/db/migration/V37__chat_turn_connection.sql b/backend/src/main/resources/db/migration/V37__chat_turn_connection.sql new file mode 100644 index 0000000..a81e572 --- /dev/null +++ b/backend/src/main/resources/db/migration/V37__chat_turn_connection.sql @@ -0,0 +1,24 @@ +-- Which connection answered a turn (spec 025, ISC-507): `llm`, which every stage uses, or the chat's +-- optional second connection beside it. Stored by name, never by base URL, so no address lands in a +-- row. Null for every turn stored before this column. Expand only; the previous image selects its +-- columns by name and never reads this one. +ALTER TABLE chat_turn + ADD COLUMN connection TEXT CHECK (connection IN ('PRIMARY', 'SECOND')); + +COMMENT ON COLUMN chat_turn.connection IS + 'The connection that answered the turn, PRIMARY or SECOND; null before V37.'; + +-- Requests the chat sent on one connection, per calendar day in the server's zone (spec 025, ISC-510). +-- +-- Beside chat_call_budget and never instead of it: a call on the second connection counts against +-- both, so the chat's own ceiling still bounds every question, and the second connection's ceiling +-- bounds what a hosted, billed model may cost in a day. Same shape, same one-statement take. +CREATE TABLE chat_connection_call_budget ( + day DATE NOT NULL, + connection TEXT NOT NULL CHECK (connection IN ('PRIMARY', 'SECOND')), + calls INT NOT NULL DEFAULT 0, + PRIMARY KEY (day, connection) +); + +COMMENT ON TABLE chat_connection_call_budget IS + 'Requests the chat sent on one connection, per calendar day in the server''s zone.'; diff --git a/backend/src/main/resources/leadgen/pipeline.yaml b/backend/src/main/resources/leadgen/pipeline.yaml index 00ad796..33315f9 100644 --- a/backend/src/main/resources/leadgen/pipeline.yaml +++ b/backend/src/main/resources/leadgen/pipeline.yaml @@ -37,6 +37,10 @@ llm: # above the database connection pool, where the extra workers would wait for a connection # and fail. concurrency: ${LLM_CONCURRENCY:1} + # Whether a call to this provider costs money, `true` or `false`. Empty decides by the kind: + # billed for `anthropic`, not billed for every other kind. Set it for an `openai-compatible` + # endpoint that is a paid service rather than a local server; the chat marks a billed model. + billed: ${LLM_BILLED:} models: # `content` labels the blocks of an advert and `fields` reads start, duration and # apply-by out of it — two bounded questions answered in three lines of JSON, which a @@ -100,6 +104,23 @@ chat: # A turn may call tools at most this often before it has to answer; the turn then ends # with its reason rather than with an answer the model never finished. max_tool_rounds: ${CHAT_MAX_TOOL_ROUNDS:6} + # A second connection only the chat reads, beside `llm` and never in its place: every pipeline + # stage keeps the provider above, and the chat's model select offers this one's models too. + # Empty `provider` is the block being absent, and the chat runs on `llm` alone, as before. + connection: + # A kind, never a default: anthropic | ollama | openai-compatible. Anything else is refused. + provider: ${CHAT_LLM_PROVIDER:} + # Required for a hosted kind; a URL in this file would be a vendor in this file. + base_url: ${CHAT_LLM_BASE_URL:} + # Required by a hosted kind; Ollama needs none. + api_key: ${CHAT_LLM_API_KEY:} + # As `llm.billed`: `true` or `false`, and empty decides by the kind. + billed: ${CHAT_LLM_BILLED:} + # How long one request on this connection may take, as `llm.timeout`. + timeout: ${CHAT_LLM_TIMEOUT:PT120S} + # This connection's own daily calls, counted beside `max_calls_per_day` above and never + # instead of it. Absent is this default, never no ceiling; 0 means no calls. + max_calls_per_day: ${CHAT_LLM_MAX_CALLS_PER_DAY:50} # When the empty chat offers a question: each trigger's line, read by rule from the screens' # own read services, before any model phrases it. Starting defaults, not yet measured on a # corpus. A trigger whose number stays below its line offers nothing. diff --git a/backend/src/test/java/de/codeministry/leadgen/ConfigurationBannerTest.java b/backend/src/test/java/de/codeministry/leadgen/ConfigurationBannerTest.java index c613825..4f1578e 100644 --- a/backend/src/test/java/de/codeministry/leadgen/ConfigurationBannerTest.java +++ b/backend/src/test/java/de/codeministry/leadgen/ConfigurationBannerTest.java @@ -41,12 +41,17 @@ private ConfigurationBanner banner() { } private ConfigurationBanner banner(PipelineConfig.Llm.Models models) { + return banner(models, null); + } + + private ConfigurationBanner banner(PipelineConfig.Llm.Models models, PipelineConfig.ChatConnection second) { var registry = Mockito.mock(ConfigRegistry.class); var snapshot = Mockito.mock(ConfigSnapshot.class); var pipeline = Mockito.mock(PipelineConfig.class); given(registry.snapshot()).willReturn(snapshot); given(snapshot.application()).willReturn(pipeline); given(pipeline.llm()).willReturn(new PipelineConfig.Llm(null, null, null, null, false, models, null)); + given(pipeline.chat()).willReturn(new PipelineConfig.Chat(null, null, null, second)); Map properties = new LinkedHashMap<>(); properties.put("server.port", "8080"); @@ -127,6 +132,52 @@ void saysNoneForEveryModelKeyWhenNoModelIsConfigured() { assertThat(row(text, "llm.models.fields → used")).contains("none"); } + @Test + void namesTheSecondConnectionWithItsKeyMaskedAndItsUrlStrippedOfCredentials() { + // ISC-514: the chat's second connection is a hosted endpoint with a key of its own. + String text = banner( + null, + new PipelineConfig.ChatConnection( + "anthropic", + "https://someone:url-Pass-77@models.example.invalid/v1", + "sk-second-Raw-Key-0042", + null, + null, + null)) + .describe(dotenv()); + + String row = row(text, "chat.connection → llm"); + assertThat(row).contains("anthropic", "billed").doesNotContain("not billed"); + assertThat(row(text, "chat.connection → llm url")) + .contains("someone:" + Secrets.MASK + "@models.example.invalid/v1"); + assertThat(text) + .as("the raw key and the URL's password, anywhere in the box") + .doesNotContain("sk-second-Raw-Key-0042") + .doesNotContain("url-Pass-77"); + assertThat(row(text, "chat.connection → llm key")).contains(Secrets.MASK); + assertThat(text.indexOf("chat.connection → llm")).isGreaterThan(text.indexOf("Language model")); + } + + @Test + void saysAnUnsetKeyApartFromAMaskedOneOnTheSecondConnection() { + String text = banner( + null, + new PipelineConfig.ChatConnection( + "ollama", "http://127.0.0.1:11434", null, "false", null, null)) + .describe(dotenv()); + + assertThat(row(text, "chat.connection → llm key")).contains(Secrets.UNSET); + assertThat(row(text, "chat.connection → llm ")).contains("ollama", "not billed"); + } + + @Test + void printsNoSecondConnectionRowWhenNoneIsConfigured() { + assertThat(banner().describe(dotenv())).doesNotContain("chat.connection"); + assertThat(banner(null, new PipelineConfig.ChatConnection("", "", "", "", null, null)) + .describe(dotenv())) + .doesNotContain("chat.connection"); + } + private static String row(String text, String key) { return text.lines().filter(line -> line.contains(key)).findFirst().orElseThrow(); } diff --git a/backend/src/test/java/de/codeministry/leadgen/chat/AnthropicStub.java b/backend/src/test/java/de/codeministry/leadgen/chat/AnthropicStub.java new file mode 100644 index 0000000..fc1c6e9 --- /dev/null +++ b/backend/src/test/java/de/codeministry/leadgen/chat/AnthropicStub.java @@ -0,0 +1,270 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * A Messages-API endpoint for the chat's second connection (spec 025, ISC-505, ISC-507): it lists + * models at {@code GET /v1/models} and streams one scripted text answer per {@code POST + * /v1/messages}, the event sequence a hosted model sends — {@code message_start}, + * {@code content_block_start}, a {@code text_delta} per part, {@code content_block_stop}, + * {@code message_delta}, {@code message_stop}. + * + *

A raw socket in the style of {@link ModelStub}, so the request bodies are read exactly as they + * left the client. {@link #baseUrl()} is the root without {@code /v1}: the client appends + * {@code /v1/messages} to its base URL itself, and the catalog's listing adds {@code /v1} where the + * base does not end in it. No vendor URL and no real model name anywhere. + */ +final class AnthropicStub implements AutoCloseable { + + private final ServerSocket server; + private final ConcurrentLinkedQueue> replies = new ConcurrentLinkedQueue<>(); + private final List bodies = Collections.synchronizedList(new ArrayList<>()); + private final List paths = Collections.synchronizedList(new ArrayList<>()); + private final AtomicReference> listed = new AtomicReference<>(List.of()); + private final AtomicInteger listings = new AtomicInteger(); + + private AnthropicStub(ServerSocket server) { + this.server = server; + Thread.ofVirtual().start(this::serve); + } + + static AnthropicStub start() { + try { + return new AnthropicStub(new ServerSocket(0, 50, InetAddress.getLoopbackAddress())); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** The root the connection's {@code base_url} names; the client adds {@code /v1/messages}. */ + String baseUrl() { + return "http://127.0.0.1:" + server.getLocalPort(); + } + + /** The models {@code GET /v1/models} lists from now on, on one page. */ + void lists(String... models) { + listed.set(List.of(models)); + } + + /** The next {@code POST /v1/messages} streams {@code parts} as text and ends the message. */ + void enqueueText(String... parts) { + replies.add(List.of(parts)); + } + + /** Every {@code POST} body the stub received, in order; listings are not among them. */ + List bodies() { + synchronized (bodies) { + return List.copyOf(bodies); + } + } + + /** The path of every {@code POST}, in order. */ + List paths() { + synchronized (paths) { + return List.copyOf(paths); + } + } + + /** How many times the model list was asked for. */ + int listings() { + return listings.get(); + } + + void reset() { + replies.clear(); + bodies.clear(); + paths.clear(); + listed.set(List.of()); + listings.set(0); + } + + @Override + public void close() { + try { + server.close(); + } catch (IOException ignored) { + // Nothing is listening any more either way. + } + } + + private void serve() { + while (!server.isClosed()) { + try { + Socket socket = server.accept(); + Thread.ofVirtual().start(() -> answer(socket)); + } catch (IOException e) { + return; + } + } + } + + private void answer(Socket socket) { + try (socket) { + InputStream in = new BufferedInputStream(socket.getInputStream()); + String[] requestLine = readLine(in).split(" "); + String body = readBody(in); + String method = requestLine.length > 0 ? requestLine[0] : ""; + String path = requestLine.length > 1 ? requestLine[1] : ""; + OutputStream out = socket.getOutputStream(); + if (method.equals("GET") && path.startsWith("/v1/models")) { + listings.incrementAndGet(); + json(out, models()); + return; + } + bodies.add(body); + paths.add(path); + List reply = replies.poll(); + if (!method.equals("POST") || !path.startsWith("/v1/messages") || reply == null) { + out.write(("HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\n" + + "Content-Length: 0\r\nConnection: close\r\n\r\n") + .getBytes(StandardCharsets.US_ASCII)); + return; + } + out.write(("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n" + + "Connection: close\r\n\r\n") + .getBytes(StandardCharsets.US_ASCII)); + out.flush(); + String model = field(body, "model"); + event( + out, + "message_start", + "{\"type\":\"message_start\",\"message\":{\"id\":\"msg_stub\"," + + "\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":" + quote(model) + + ",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":1," + + "\"output_tokens\":1,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0}}}"); + event( + out, + "content_block_start", + "{\"type\":\"content_block_start\",\"index\":0," + + "\"content_block\":{\"type\":\"text\",\"text\":\"\"}}"); + for (String part : reply) { + event( + out, + "content_block_delta", + "{\"type\":\"content_block_delta\",\"index\":0," + + "\"delta\":{\"type\":\"text_delta\",\"text\":" + quote(part) + "}}"); + } + event(out, "content_block_stop", "{\"type\":\"content_block_stop\",\"index\":0}"); + event( + out, + "message_delta", + "{\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"," + + "\"stop_sequence\":null},\"usage\":{\"output_tokens\":" + reply.size() + "}}"); + event(out, "message_stop", "{\"type\":\"message_stop\"}"); + out.write("0\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + out.flush(); + } catch (IOException ignored) { + // The client went away; the test reads what arrived. + } + } + + private String models() { + StringBuilder data = new StringBuilder(); + List models = listed.get(); + for (String model : models) { + data.append(data.isEmpty() ? "" : ",") + .append("{\"type\":\"model\",\"id\":") + .append(quote(model)) + .append(",\"display_name\":") + .append(quote(model)) + .append(",\"created_at\":\"2026-01-01T00:00:00Z\"}"); + } + String first = models.isEmpty() ? "null" : quote(models.getFirst()); + String last = models.isEmpty() ? "null" : quote(models.getLast()); + return "{\"data\":[" + data + "],\"has_more\":false,\"first_id\":" + first + ",\"last_id\":" + last + "}"; + } + + private static void json(OutputStream out, String json) throws IOException { + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + out.write(("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + bytes.length + + "\r\nConnection: close\r\n\r\n") + .getBytes(StandardCharsets.US_ASCII)); + out.write(bytes); + out.flush(); + } + + private static void event(OutputStream out, String name, String data) throws IOException { + byte[] bytes = ("event: " + name + "\ndata: " + data + "\n\n").getBytes(StandardCharsets.UTF_8); + out.write((Integer.toHexString(bytes.length) + "\r\n").getBytes(StandardCharsets.US_ASCII)); + out.write(bytes); + out.write("\r\n".getBytes(StandardCharsets.US_ASCII)); + out.flush(); + } + + /** A top-level string field of the request body, or an empty string. */ + private static String field(String json, String name) { + try { + return new com.fasterxml.jackson.databind.ObjectMapper() + .readTree(json) + .path(name) + .asText(""); + } catch (IOException e) { + return ""; + } + } + + private static String quote(String text) { + return "\"" + text.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\""; + } + + private static String readBody(InputStream in) throws IOException { + int length = -1; + boolean chunked = false; + String line; + while (!(line = readLine(in)).isEmpty()) { + String lower = line.toLowerCase(Locale.ROOT); + if (lower.startsWith("content-length:")) { + length = Integer.parseInt(line.substring(15).strip()); + } else if (lower.startsWith("transfer-encoding:") && lower.contains("chunked")) { + chunked = true; + } + } + ByteArrayOutputStream body = new ByteArrayOutputStream(); + if (chunked) { + int size; + while ((size = Integer.parseInt(readLine(in).strip(), 16)) > 0) { + body.write(in.readNBytes(size)); + readLine(in); + } + readLine(in); + } else if (length > 0) { + body.write(in.readNBytes(length)); + } + return body.toString(StandardCharsets.UTF_8); + } + + private static String readLine(InputStream in) throws IOException { + StringBuilder line = new StringBuilder(); + int c; + while ((c = in.read()) >= 0 && c != '\n') { + if (c != '\r') { + line.append((char) c); + } + } + return line.toString(); + } +} diff --git a/backend/src/test/java/de/codeministry/leadgen/chat/ChatBudgetTest.java b/backend/src/test/java/de/codeministry/leadgen/chat/ChatBudgetTest.java index c6f3716..d01c24f 100644 --- a/backend/src/test/java/de/codeministry/leadgen/chat/ChatBudgetTest.java +++ b/backend/src/test/java/de/codeministry/leadgen/chat/ChatBudgetTest.java @@ -13,6 +13,7 @@ import de.codeministry.leadgen.Databases; import de.codeministry.leadgen.config.ConfigFixtures; import de.codeministry.leadgen.config.ConfigRegistry; +import de.codeministry.leadgen.llm.Connections; import de.codeministry.leadgen.llm.LlmBudget; import java.io.IOException; import java.io.UncheckedIOException; @@ -20,6 +21,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.function.UnaryOperator; @@ -31,9 +33,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.assertj.MockMvcTester; +import org.springframework.web.context.WebApplicationContext; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; @@ -60,6 +65,9 @@ class ChatBudgetTest { private static final ModelStub MODEL = ModelStub.start(); + /** The chat's second connection, where a test points {@code chat.connection} at it (spec 025). */ + private static final ModelStub SECOND = ModelStub.start(); + /** The shipped {@code pipeline.yaml} pointed at the stub; copied over the fixture before each test. */ private static final Path STUBBED = MODEL.configuration().resolve("pipeline.yaml"); @@ -78,6 +86,12 @@ class ChatBudgetTest { @Autowired private JdbcTemplate jdbc; + @Autowired + private ConversationRepository conversations; + + @Autowired + private WebApplicationContext context; + @DynamicPropertySource static void configuration(DynamicPropertyRegistry registry) { registry.add("leadgen.config-dir", CONFIG_DIRECTORY::toString); @@ -86,13 +100,16 @@ static void configuration(DynamicPropertyRegistry registry) { @AfterAll static void stop() { MODEL.close(); + SECOND.close(); } @BeforeEach void reset() throws IOException { MODEL.reset(); + SECOND.reset(); jdbc.update("DELETE FROM chat_conversation"); jdbc.update("DELETE FROM chat_call_budget"); + jdbc.update("DELETE FROM chat_connection_call_budget"); jdbc.update("DELETE FROM llm_call_budget"); ConfigFixtures.materialize(CONFIG_DIRECTORY); Files.copy(STUBBED, CONFIG_DIRECTORY.resolve("pipeline.yaml"), StandardCopyOption.REPLACE_EXISTING); @@ -142,6 +159,154 @@ void aSpentRoundEndsARealTurnWithTheRoundReason() { assertThat(pipelineBudget.used()).isZero(); } + /** + * ISC-506: a turn and a regenerate asked with a name no connection listed, or with a listed name + * under the other connection, are refused over HTTP before anything starts — no model request at + * either stub, and neither chat counter nor {@code llm.budget} moves. + */ + @Test + void aPickTheServerDidNotDiscoverIsRefusedAndMovesNoCounter() { + rewrite(text -> ModelStub.pointSecondAt(text, "openai-compatible", SECOND.baseUrl())); + MODEL.lists(ModelStub.MODEL, "model-a"); + SECOND.lists("model-b"); + long conversation = + jdbc.queryForObject("INSERT INTO chat_conversation (title) VALUES ('') RETURNING id", Long.class); + long earlier = conversations.startTurn(conversation, "Which Java roles?", ModelStub.MODEL); + MockMvcTester mvc = MockMvcTester.from(context); + // The drawer reads the list before it offers a pick; the refusals then read only that list. + assertThat(mvc.get().uri("/api/v1/chat/models")).hasStatusOk(); + int pipelineBefore = pipelineBudget.used(); + + for (String pick : List.of( + "\"connection\":\"SECOND\",\"model\":\"model-unlisted\"", + "\"connection\":\"PRIMARY\",\"model\":\"model-b\"")) { + assertThat(mvc.post() + .uri("/api/v1/chat/conversations/" + conversation + "/turns") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"question\":\"Which Java roles?\"," + pick + "}")) + .as("ask, " + pick) + .hasStatus(400); + assertThat(mvc.post() + .uri("/api/v1/chat/conversations/" + conversation + "/turns/" + earlier + "/regenerate") + .contentType(MediaType.APPLICATION_JSON) + .content("{" + pick + "}")) + .as("regenerate, " + pick) + .hasStatus(400); + } + + assertThat(MODEL.bodies()).as("requests at the primary stub").isEmpty(); + assertThat(SECOND.bodies()).as("requests at the second stub").isEmpty(); + assertThat(List.of(MODEL.listings(), SECOND.listings())) + .as("each list read once, by the drawer") + .containsExactly(1, 1); + assertThat(budget.used()).isZero(); + assertThat(jdbc.queryForObject("SELECT coalesce(sum(calls), 0) FROM chat_call_budget", Integer.class)) + .isZero(); + assertThat(jdbc.queryForObject( + "SELECT coalesce(sum(calls), 0) FROM chat_connection_call_budget", Integer.class)) + .isZero(); + assertThat(pipelineBudget.used()).isEqualTo(pipelineBefore).isZero(); + assertThat(jdbc.queryForObject( + "SELECT count(*) FROM chat_turn WHERE conversation_id = ?", Integer.class, conversation)) + .as("no turn written") + .isEqualTo(1); + } + + /** + * ISC-510: the second connection's ceiling of 2 beside the chat's 10. The third turn on the + * second connection ends {@code BUDGET} with two requests at its stub, and a turn on the primary + * in the same conversation still answers; the refused third counted on neither counter, + * {@code llm.budget} never moved, and the status names both counters. + */ + @Test + void aSpentSecondConnectionEndsItsTurnWhileThePrimaryStillAnswers() { + rewrite(text -> ModelStub.pointSecondAt(text, "openai-compatible", SECOND.baseUrl())); + chat("max_calls_per_day", "10"); + second("max_calls_per_day", "2"); + int pipelineBefore = pipelineBudget.used(); + for (int reply = 0; reply < 3; reply++) { + SECOND.enqueue(ModelStub.text(Duration.ZERO, "From the second.")); + } + MODEL.enqueue(ModelStub.text(Duration.ZERO, "From the primary.")); + long conversation = + jdbc.queryForObject("INSERT INTO chat_conversation (title) VALUES ('') RETURNING id", Long.class); + + List> picked = new ArrayList<>(); + for (int turn = 0; turn < 3; turn++) { + List events = new ArrayList<>(); + turns.ask(conversation, "Which Java roles?", new ModelPick("SECOND", "model-b"), events::add); + picked.add(events); + } + List primary = new ArrayList<>(); + turns.ask(conversation, "And on the primary?", primary::add); + + assertThat(picked.get(0).getLast()).isInstanceOf(ChatDone.class); + assertThat(picked.get(1).getLast()).isInstanceOf(ChatDone.class); + assertThat(picked.get(2).getLast()) + .isInstanceOfSatisfying( + ChatError.class, error -> assertThat(error.reason()).isEqualTo(ChatErrorReason.BUDGET)); + assertThat(SECOND.bodies()).as("requests at the second stub").hasSize(2); + assertThat(primary.getLast()).as("the primary still answers").isInstanceOf(ChatDone.class); + assertThat(MODEL.bodies()).hasSize(1); + assertThat(List.of(budget.used(), budget.used(Connections.Name.SECOND))) + .as("the chat's counter and the second's; the refused third on neither") + .containsExactly(3, 2); + assertThat(pipelineBudget.used()).isEqualTo(pipelineBefore).isZero(); + + var status = assertThat(MockMvcTester.from(context).get().uri("/api/v1/chat/status")) + .hasStatusOk() + .bodyJson(); + status.extractingPath("$.callsUsed").isEqualTo(3); + status.extractingPath("$.callsLimit").isEqualTo(10); + status.extractingPath("$.secondCalls").isEqualTo(2); + status.extractingPath("$.secondLimit").isEqualTo(2); + } + + /** ISC-510: without the key, the second connection's ceiling is the shipped default, never none. */ + @Test + void anAbsentSecondCeilingIsTheShippedDefault() { + rewrite(text -> ModelStub.pointSecondAt(text, "openai-compatible", SECOND.baseUrl())); + rewrite(text -> text.replaceAll("(?m)^ max_calls_per_day: \\$\\{CHAT_LLM_MAX_CALLS_PER_DAY[^}]*}\\n", "")); + jdbc.update( + "INSERT INTO chat_connection_call_budget (day, connection, calls) VALUES (current_date, 'SECOND', ?)", + ChatBudget.DEFAULT_SECOND_CALLS_PER_DAY - 1); + + assertThat(budget.secondLimit()).hasValue(ChatBudget.DEFAULT_SECOND_CALLS_PER_DAY); + assertThat(ChatBudget.DEFAULT_SECOND_CALLS_PER_DAY).isEqualTo(50); + assertThat(budget.take(Connections.Name.SECOND)).isTrue(); + assertThat(budget.take(Connections.Name.SECOND)).isFalse(); + assertThat(budget.used(Connections.Name.SECOND)).isEqualTo(50); + assertThat(budget.used()) + .as("the refused call given back on the chat's counter") + .isEqualTo(1); + } + + /** ISC-510: a call the chat's own ceiling refuses never moves the second connection's counter. */ + @Test + void aSecondCallTheChatRefusesCountsOnNeither() { + rewrite(text -> ModelStub.pointSecondAt(text, "openai-compatible", SECOND.baseUrl())); + chat("max_calls_per_day", "1"); + second("max_calls_per_day", "5"); + + assertThat(budget.take(Connections.Name.SECOND)).isTrue(); + assertThat(budget.take(Connections.Name.SECOND)).isFalse(); + assertThat(budget.take(Connections.Name.PRIMARY)).isFalse(); + + assertThat(List.of(budget.used(), budget.used(Connections.Name.SECOND))).containsExactly(1, 1); + assertThat(budget.used(Connections.Name.PRIMARY)).isEqualTo(1); + } + + /** ISC-510: without a second connection the status names only the chat's counter. */ + @Test + void withoutASecondConnectionThereIsNoSecondCeiling() { + assertThat(budget.secondLimit()).isEmpty(); + var status = assertThat(MockMvcTester.from(context).get().uri("/api/v1/chat/status")) + .hasStatusOk() + .bodyJson(); + status.extractingPath("$.secondCalls").isNull(); + status.extractingPath("$.secondLimit").isNull(); + } + /** One turn on this thread, its events collected. */ private List turn() { long conversation = @@ -291,6 +456,17 @@ private void chat(String key, String value) { }); } + /** Sets a key inside the {@code chat.connection} block. */ + private void second(String key, String value) { + rewrite(text -> { + int block = text.indexOf("\n connection:\n"); + if (block < 0) { + throw new IllegalStateException("no `chat.connection:` in the shipped pipeline.yaml"); + } + return text.substring(0, block) + set(text.substring(block), key, value); + }); + } + /** Sets a key in the pipeline's {@code llm.budget} block, which comes before {@code chat:}. */ private void pipeline(String key, String value) { rewrite(text -> set(text, key, value)); diff --git a/backend/src/test/java/de/codeministry/leadgen/chat/ChatConnectionConfigTest.java b/backend/src/test/java/de/codeministry/leadgen/chat/ChatConnectionConfigTest.java new file mode 100644 index 0000000..29ea350 --- /dev/null +++ b/backend/src/test/java/de/codeministry/leadgen/chat/ChatConnectionConfigTest.java @@ -0,0 +1,301 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import de.codeministry.leadgen.Databases; +import de.codeministry.leadgen.config.ConfigFixtures; +import de.codeministry.leadgen.config.ConfigRegistry; +import de.codeministry.leadgen.config.ConfigSource; +import de.codeministry.leadgen.config.ConfigValidationException; +import de.codeministry.leadgen.config.model.PipelineConfig; +import de.codeministry.leadgen.llm.ChatModels; +import de.codeministry.leadgen.llm.Connections; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * ISC-504: the chat's optional second connection in {@code pipeline.yaml}, and the {@code billed} + * key on each connection. + * + *

The load cases go through the shipped file with its placeholders filled from a map, so what + * is under test is the block that ships, not a fixture of its own. The one turn runs in a context + * whose {@code pipeline.yaml} has the block cut out entirely, against the stub model, because + * "absent runs the chat as before" is a claim about a real turn and not about a bound record. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +class ChatConnectionConfigTest { + + private static final ModelStub MODEL = ModelStub.start(); + + /** Created once, never inside the property supplier — see {@code LlmBudgetTest}. */ + private static final Path CONFIG = withoutSecondConnection(MODEL.configuration()); + + private static final ValidatorFactory FACTORY = Validation.buildDefaultValidatorFactory(); + private static final Validator VALIDATOR = FACTORY.getValidator(); + + /** A loopback address no test dials: the load cases only bind, they never call. */ + private static final String SOME_URL = "http://127.0.0.1:9/v1"; + + private static final List KINDS = + List.of(ChatModels.OLLAMA, ChatModels.ANTHROPIC, ChatModels.OPENAI_COMPATIBLE); + + @Container + @ServiceConnection + static final PostgreSQLContainer POSTGRES = Databases.postgres(); + + @DynamicPropertySource + static void configuration(DynamicPropertyRegistry registry) { + registry.add("leadgen.config-dir", CONFIG::toString); + } + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private ConfigRegistry config; + + @TempDir + Path configDir; + + @AfterAll + static void stop() { + MODEL.close(); + FACTORY.close(); + } + + @BeforeEach + void reset() { + MODEL.reset(); + ConfigFixtures.materialize(configDir); + jdbc.update("DELETE FROM chat_conversation"); + jdbc.update("DELETE FROM chat_call_budget"); + } + + @Test + void anUnknownProviderKindIsRefusedNamingTheKey() { + assertThatThrownBy(() -> load(Map.of("CHAT_LLM_PROVIDER", "carrier-pigeon", "CHAT_LLM_BASE_URL", SOME_URL))) + .isInstanceOf(ConfigValidationException.class) + .hasMessageContaining("chat.connection.provider") + .hasMessageContaining("carrier-pigeon"); + } + + @Test + void aHostedKindWithoutABaseUrlIsRefusedNamingTheKey() { + for (String hosted : List.of(ChatModels.ANTHROPIC, ChatModels.OPENAI_COMPATIBLE)) { + assertThatThrownBy(() -> load(Map.of("CHAT_LLM_PROVIDER", hosted, "CHAT_LLM_API_KEY", "k"))) + .as(hosted) + .isInstanceOf(ConfigValidationException.class) + .hasMessageContaining("chat.connection.base_url") + .hasMessageContaining(hosted); + } + } + + @Test + void aBilledValueThatIsNeitherTrueNorFalseIsRefusedNamingTheKey() { + assertThatThrownBy(() -> load(Map.of( + "CHAT_LLM_PROVIDER", + ChatModels.OLLAMA, + "CHAT_LLM_BASE_URL", + SOME_URL, + "CHAT_LLM_BILLED", + "maybe"))) + .isInstanceOf(ConfigValidationException.class) + .hasMessageContaining("chat.connection.billed"); + assertThatThrownBy(() -> load(Map.of("LLM_BILLED", "yes"))) + .isInstanceOf(ConfigValidationException.class) + .hasMessageContaining("llm.billed"); + } + + @Test + void ollamaLoadsWithoutAKey() { + PipelineConfig pipeline = load(Map.of("CHAT_LLM_PROVIDER", ChatModels.OLLAMA, "CHAT_LLM_BASE_URL", SOME_URL)); + + Connections.Connection second = Connections.second(pipeline).orElseThrow(); + assertThat(second.name()).isEqualTo(Connections.Name.SECOND); + assertThat(second.kind()).isEqualTo(ChatModels.OLLAMA); + assertThat(second.llm().apiKey()).isNullOrEmpty(); + assertThat(second.llm().baseUrl()).isEqualTo(SOME_URL); + assertThat(pipeline.chat().connection().maxCallsPerDay()).isEqualTo(50); + assertThat(second.llm().timeout()).isEqualTo(PipelineConfig.Llm.DEFAULT_TIMEOUT); + assertThat(Connections.all(pipeline)) + .extracting(Connections.Connection::name) + .containsExactly(Connections.Name.PRIMARY, Connections.Name.SECOND); + } + + @Test + void theShippedBlockEmptyOrCutOutLeavesThePrimaryAlone() throws IOException { + PipelineConfig empty = load(Map.of()); + assertThat(Connections.second(empty)).isEmpty(); + assertThat(Connections.all(empty)) + .extracting(Connections.Connection::name) + .containsExactly(Connections.Name.PRIMARY); + + Path pipeline = configDir.resolve("pipeline.yaml"); + Files.writeString(pipeline, cut(Files.readString(pipeline, StandardCharsets.UTF_8)), StandardCharsets.UTF_8); + PipelineConfig absent = load(Map.of()); + assertThat(absent.chat().connection()).isNull(); + assertThat(Connections.second(absent)).isEmpty(); + } + + @Test + void billedIsReadAsSetAndWhenAbsentIsTrueForTheHostedKindOnly() { + for (String kind : KINDS) { + Map expected = new HashMap<>(); + expected.put("", ChatModels.ANTHROPIC.equals(kind)); + expected.put("true", true); + expected.put("false", false); + expected.forEach((billed, want) -> { + Map env = new HashMap<>(); + env.put("LLM_PROVIDER", kind); + env.put("LLM_BASE_URL", SOME_URL); + env.put("LLM_BILLED", billed); + env.put("CHAT_LLM_PROVIDER", kind); + env.put("CHAT_LLM_BASE_URL", SOME_URL); + env.put("CHAT_LLM_BILLED", billed); + PipelineConfig pipeline = load(env); + + assertThat(Connections.primary(pipeline).billed()) + .as("llm, %s, billed '%s'", kind, billed) + .isEqualTo(want); + assertThat(Connections.second(pipeline).orElseThrow().billed()) + .as("chat.connection, %s, billed '%s'", kind, billed) + .isEqualTo(want); + }); + } + } + + @Test + void theStubRewritesTheLlmBlockAndLeavesTheSecondConnectionAlone() throws IOException { + String rewritten = Files.readString(MODEL.configuration().resolve("pipeline.yaml"), StandardCharsets.UTF_8); + + assertThat(rewritten).contains(" provider: openai-compatible", " base_url: " + MODEL.baseUrl()); + assertThat(rewritten).contains(" provider: ${CHAT_LLM_PROVIDER:}", " base_url: ${CHAT_LLM_BASE_URL:}"); + } + + /** + * The shipped block, read the way {@code rg} would: every value a placeholder, and none of + * them defaulting to a vendor, a URL or a model — only the timeout and the ceiling carry one. + */ + @Test + void everyShippedValueIsAPlaceholderWithAnEmptyDefault() { + String shipped = + ConfigSource.fromClasspath("pipeline.yaml").orElseThrow().content(); + + assertThat(shipped).containsPattern("(?m)^ billed: \\$\\{LLM_BILLED:}$"); + List values = connectionBlock(shipped); + assertThat(values) + .containsExactlyInAnyOrder( + "provider: ${CHAT_LLM_PROVIDER:}", + "base_url: ${CHAT_LLM_BASE_URL:}", + "api_key: ${CHAT_LLM_API_KEY:}", + "billed: ${CHAT_LLM_BILLED:}", + "timeout: ${CHAT_LLM_TIMEOUT:PT120S}", + "max_calls_per_day: ${CHAT_LLM_MAX_CALLS_PER_DAY:50}"); + } + + @Test + void withTheBlockAbsentATurnIsAnsweredOnThePrimaryConnection() { + assertThat(config.snapshot().application().chat().connection()).isNull(); + assertThat(Connections.all(config.snapshot().application())) + .extracting(Connections.Connection::name) + .containsExactly(Connections.Name.PRIMARY); + MODEL.enqueue(ModelStub.text(java.time.Duration.ZERO, "On the primary.")); + long conversation = + jdbc.queryForObject("INSERT INTO chat_conversation (title) VALUES ('') RETURNING id", Long.class); + + List events = TurnStream.ask(port, conversation, "Which connection?"); + + assertThat(TurnStream.names(events).getLast()).isEqualTo("done"); + assertThat(TurnStream.text(events)).isEqualTo("On the primary."); + assertThat(MODEL.bodies()).hasSize(1); + assertThat(jdbc.queryForObject("SELECT model FROM chat_turn", String.class)) + .isEqualTo(ModelStub.MODEL); + } + + private PipelineConfig load(Map env) { + return ConfigFixtures.loaderFor(configDir, VALIDATOR, env).load().application(); + } + + /** The lines under {@code chat.connection}, each trimmed and with its comment dropped. */ + private static List connectionBlock(String yaml) { + Matcher block = + Pattern.compile("(?m)^ connection:\\R((?:(?: {4}.*)?\\R)*)").matcher(yaml); + assertThat(block.find()).as("a chat.connection block ships").isTrue(); + List values = new ArrayList<>(); + for (String line : block.group(1).split("\\R")) { + String trimmed = line.strip(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { + values.add(trimmed.replaceFirst("\\s+#.*$", "")); + } + } + return values; + } + + /** {@code yaml} with the {@code chat.connection} block cut out, comments and all. */ + static String cut(String yaml) { + StringBuilder out = new StringBuilder(); + boolean skipping = false; + for (String line : yaml.split("\n", -1)) { + if (line.equals(" connection:")) { + skipping = true; + continue; + } + if (skipping && (line.startsWith(" ") || line.isBlank())) { + continue; + } + skipping = false; + out.append(line).append('\n'); + } + return out.toString(); + } + + private static Path withoutSecondConnection(Path dir) { + try { + Path pipeline = dir.resolve("pipeline.yaml"); + Files.writeString( + pipeline, cut(Files.readString(pipeline, StandardCharsets.UTF_8)), StandardCharsets.UTF_8); + return dir; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/backend/src/test/java/de/codeministry/leadgen/chat/ChatModelCatalogTest.java b/backend/src/test/java/de/codeministry/leadgen/chat/ChatModelCatalogTest.java new file mode 100644 index 0000000..e60eee2 --- /dev/null +++ b/backend/src/test/java/de/codeministry/leadgen/chat/ChatModelCatalogTest.java @@ -0,0 +1,796 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; +import ch.qos.logback.core.read.ListAppender; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import de.codeministry.leadgen.chat.catalog.ChatModelCatalog; +import de.codeministry.leadgen.chat.catalog.ChatModelsView; +import de.codeministry.leadgen.chat.catalog.ConnectionView; +import de.codeministry.leadgen.chat.catalog.ModelView; +import de.codeministry.leadgen.config.ConfigRegistry; +import de.codeministry.leadgen.config.ConfigSnapshot; +import de.codeministry.leadgen.config.model.PipelineConfig; +import de.codeministry.leadgen.llm.ChatModels; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.slf4j.LoggerFactory; + +/** + * ISC-505: the chat lists, per configured connection, the models that connection reports and that + * can call tools — read from the connection, never from a list in {@code .env}. A connection that + * does not answer is listed with its reason and no models, and never takes the other one down. + * + *

Two WireMock servers stand in for the two connections: the primary speaks Ollama's native + * API, the second the hosted kind's paged model list. Every model name is neutral. + * + *

The concurrent cases never coordinate by the clock: a {@link GatedOllama} holds every model + * list until the test releases it and says when a request has arrived, each caller thread reads its + * own snapshot object, and the registry mock is stubbed once and never again while one runs. + */ +class ChatModelCatalogTest { + + private static final WireMockServer PRIMARY = + new WireMockServer(WireMockConfiguration.options().dynamicPort()); + private static final WireMockServer SECOND = + new WireMockServer(WireMockConfiguration.options().dynamicPort()); + + static { + PRIMARY.start(); + SECOND.start(); + } + + /** How long a test waits on something that should already be happening; only a hang reaches it. */ + private static final Duration HANG = Duration.ofSeconds(10); + + /** The snapshot a caller thread holds, when it is not the registry's current one. */ + private static final ThreadLocal PINNED = new ThreadLocal<>(); + + private final ConfigRegistry registry = Mockito.mock(ConfigRegistry.class); + private final AtomicReference current = new AtomicReference<>(); + + @AfterAll + static void stop() { + PRIMARY.stop(); + SECOND.stop(); + } + + @BeforeEach + void reset() { + // Stubbed once: a reload swaps what `current` holds, never the stubbing a caller thread is reading. + given(registry.snapshot()).willAnswer(_ -> { + ConfigSnapshot pinned = PINNED.get(); + return pinned != null ? pinned : current.get(); + }); + PRIMARY.resetAll(); + SECOND.resetAll(); + ollamaListsThreeModelsOneWithoutTools(); + hostedListsTwoModelsOverTwoPages(); + } + + @Test + void listsEveryToolCapableModelPerConnectionWithItsFlags() { + use(ollamaPrimary(), hostedSecond()); + + ChatModelsView view = new ChatModelCatalog(registry).models(); + + assertThat(view.connections()).extracting(ConnectionView::name).containsExactly("PRIMARY", "SECOND"); + assertThat(view.defaultModelName()).isEqualTo("model-a"); + ConnectionView primary = view.connections().get(0); + assertThat(primary.kind()).isEqualTo(ChatModels.OLLAMA); + assertThat(primary.billed()).isFalse(); + assertThat(primary.reachable()).isTrue(); + assertThat(primary.reason()).isNull(); + // Three reported, the one without `tools` among its capabilities is absent. + assertThat(primary.models()).containsExactly(new ModelView("model-a", true), new ModelView("model-b", false)); + + ConnectionView second = view.connections().get(1); + assertThat(second.kind()).isEqualTo(ChatModels.ANTHROPIC); + assertThat(second.billed()).isTrue(); + assertThat(second.reachable()).isTrue(); + // Two pages, one model each; the default lives on the primary only. + assertThat(second.models()).containsExactly(new ModelView("model-x", false), new ModelView("model-y", false)); + + // Five reported in all, four listed. + PRIMARY.verify(1, getRequestedFor(urlPathEqualTo("/api/tags"))); + PRIMARY.verify(3, postRequestedFor(urlPathEqualTo("/api/show"))); + SECOND.verify(getRequestedFor(urlPathEqualTo("/v1/models")) + .withHeader("x-api-key", equalTo("second-key")) + .withHeader("anthropic-version", matching(".+"))); + SECOND.verify(getRequestedFor(urlPathEqualTo("/v1/models")).withQueryParam("after_id", equalTo("model-x"))); + } + + @Test + void aConnectionAnsweringAnErrorIsListedWithItsReasonAndTheOtherStillLists() { + SECOND.resetAll(); + SECOND.stubFor(get(urlPathEqualTo("/v1/models")).willReturn(aResponse().withStatus(500))); + use(ollamaPrimary(), hostedSecond()); + + ChatModelsView view = new ChatModelCatalog(registry).models(); + + ConnectionView primary = view.connections().get(0); + assertThat(primary.reachable()).isTrue(); + assertThat(primary.models()).extracting(ModelView::name).containsExactly("model-a", "model-b"); + + ConnectionView second = view.connections().get(1); + assertThat(second.name()).isEqualTo("SECOND"); + assertThat(second.reachable()).isFalse(); + assertThat(second.reason()).isEqualTo("the model list answered HTTP 500"); + assertThat(second.models()).isEmpty(); + assertThat(second.billed()).isTrue(); + } + + @Test + void aConnectionNobodyListensOnIsListedWithItsReason() { + var gone = new PipelineConfig.ChatConnection( + ChatModels.ANTHROPIC, "http://127.0.0.1:1", "second-key", null, null, null); + use(ollamaPrimary(), gone); + + ChatModelsView view = new ChatModelCatalog(registry).models(); + + assertThat(view.connections().get(0).models()).hasSize(2); + assertThat(view.connections().get(1).reachable()).isFalse(); + assertThat(view.connections().get(1).reason()).isNotBlank(); + } + + @Test + void withoutASecondConnectionOnlyThePrimaryIsListed() { + use(ollamaPrimary(), null); + + ChatModelsView view = new ChatModelCatalog(registry).models(); + + assertThat(view.connections()).extracting(ConnectionView::name).containsExactly("PRIMARY"); + } + + @Test + void anOpenAiCompatibleConnectionListsEveryModelWithItsBearerKey() { + SECOND.resetAll(); + SECOND.stubFor(get(urlPathEqualTo("/v1/models")) + .willReturn(okJson("{\"object\":\"list\",\"data\":[{\"id\":\"model-p\"},{\"id\":\"model-q\"}]}"))); + var compatible = new PipelineConfig.ChatConnection( + ChatModels.OPENAI_COMPATIBLE, SECOND.baseUrl() + "/v1", "second-key", "true", null, null); + use(ollamaPrimary(), compatible); + + ConnectionView second = + new ChatModelCatalog(registry).models().connections().get(1); + + assertThat(second.kind()).isEqualTo(ChatModels.OPENAI_COMPATIBLE); + assertThat(second.billed()).isTrue(); + assertThat(second.models()).extracting(ModelView::name).containsExactly("model-p", "model-q"); + SECOND.verify(getRequestedFor(urlPathEqualTo("/v1/models")) + .withHeader("Authorization", equalTo("Bearer second-key"))); + } + + @Test + void aSecondCallWithinTheMinuteAsksNobodyAndANewSnapshotAsksAgain() { + use(ollamaPrimary(), hostedSecond()); + var catalog = new ChatModelCatalog(registry); + + catalog.models(); + catalog.models(); + PRIMARY.verify(1, getRequestedFor(urlPathEqualTo("/api/tags"))); + + // A reload hands out a different snapshot object, and the cached list goes with the old one. + use(ollamaPrimary(), hostedSecond()); + catalog.models(); + PRIMARY.verify(2, getRequestedFor(urlPathEqualTo("/api/tags"))); + } + + @Test + void containsReadsTheCachedListAndNeverFetches() { + use(ollamaPrimary(), hostedSecond()); + var catalog = new ChatModelCatalog(registry); + + assertThat(catalog.contains("PRIMARY", "model-a")).isFalse(); + PRIMARY.verify(0, getRequestedFor(urlPathEqualTo("/api/tags"))); + + catalog.models(); + + assertThat(catalog.contains("PRIMARY", "model-a")).isTrue(); + assertThat(catalog.contains("SECOND", "model-y")).isTrue(); + assertThat(catalog.contains("PRIMARY", "model-c")).isFalse(); + assertThat(catalog.contains("SECOND", "model-a")).isFalse(); + assertThat(catalog.contains("ELSEWHERE", "model-a")).isFalse(); + PRIMARY.verify(1, getRequestedFor(urlPathEqualTo("/api/tags"))); + } + + /** + * Every kind but Ollama needs a key before a chat model is built on it, so a keyless one could + * list models no turn can use. It is listed unreachable with the fixed reason, and never asked. + */ + @Test + void aKeylessHostedConnectionIsUnreachableAndNeverAsked() { + SECOND.resetAll(); + SECOND.stubFor(get(urlPathEqualTo("/v1/models")).willReturn(okJson("{\"data\":[{\"id\":\"model-p\"}]}"))); + var keyless = new PipelineConfig.ChatConnection( + ChatModels.OPENAI_COMPATIBLE, SECOND.baseUrl() + "/v1", null, null, null, null); + use(ollamaPrimary(), keyless); + + ConnectionView second = + new ChatModelCatalog(registry).models().connections().get(1); + + assertThat(second.reachable()).isFalse(); + assertThat(second.reason()).isEqualTo("no API key is configured for this connection"); + assertThat(second.models()).isEmpty(); + assertThat(SECOND.getAllServeEvents()).isEmpty(); + } + + /** A 3xx is not followed, because the request carries the connection's key; its target is never asked. */ + @Test + void aRedirectIsUnreachableAndItsTargetNeverAsked() { + SECOND.resetAll(); + SECOND.stubFor(get(urlPathEqualTo("/v1/models")) + .willReturn(aResponse().withStatus(302).withHeader("Location", SECOND.baseUrl() + "/elsewhere"))); + SECOND.stubFor(get(urlPathEqualTo("/elsewhere")).willReturn(okJson("{\"data\":[{\"id\":\"model-x\"}]}"))); + use(ollamaPrimary(), hostedSecond()); + + ConnectionView second = + new ChatModelCatalog(registry).models().connections().get(1); + + assertThat(second.reachable()).isFalse(); + assertThat(second.reason()).isEqualTo("the model list answered with a redirect"); + SECOND.verify(0, getRequestedFor(urlPathEqualTo("/elsewhere"))); + } + + /** A body that is not JSON is "could not be read", never a parser's snippet of what it said. */ + @Test + void anUnparseableBodyIsUnreadableWithoutQuotingIt() { + SECOND.resetAll(); + SECOND.stubFor(get(urlPathEqualTo("/v1/models")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("internal-detail-from-the-body {not json"))); + use(ollamaPrimary(), hostedSecond()); + + ConnectionView second = + new ChatModelCatalog(registry).models().connections().get(1); + + assertThat(second.reachable()).isFalse(); + assertThat(second.reason()) + .isEqualTo("the model list could not be read") + .doesNotContain("internal-detail"); + } + + /** + * No lock is held across the network, and two callers missing together wait on one discovery + * rather than each starting their own: the held primary is asked once. + */ + @Test + void twoConcurrentMissesShareOneDiscovery() throws Exception { + try (var gate = new GatedOllama()) { + use(ollamaAt(gate.baseUrl("/a")), null); + var catalog = new ChatModelCatalog(registry); + + var first = new Caller(catalog, null); + gate.awaitArrival("/a"); + var second = new Caller(catalog, null); + second.awaitParked(); + gate.release(); + + assertThat(first.view()).isSameAs(second.view()); + assertThat(gate.listings("/a")).isEqualTo(1); + } + } + + /** + * A discovery in flight holds nobody else up: after a reload, a caller under the new + * configuration is answered while the old one is still held at its primary. + */ + @Test + void aSlowDiscoveryDoesNotHoldUpACallerUnderANewConfiguration() throws Exception { + try (var gate = new GatedOllama()) { + use(ollamaAt(gate.baseUrl("/a")), null); + var catalog = new ChatModelCatalog(registry); + var slow = new Caller(catalog, null); + gate.awaitArrival("/a"); + + // The reload: the primary is now the hosted stub, which answers at once. + use(hostedPrimary(), null); + ChatModelsView fast = catalog.models(); + + assertThat(fast.connections().get(0).models()) + .extracting(ModelView::name) + .containsExactly("model-x", "model-y"); + assertThat(slow.done()).isFalse(); + gate.release(); + slow.view(); + + // The slow list belongs to the old configuration and is not published over the new one. + assertThat(catalog.contains("PRIMARY", "model-x")).isTrue(); + } + } + + /** + * A caller still holding the snapshot from before a reload waits on that snapshot's discovery + * and never displaces the new one's, so a caller under the new snapshot does not start a second. + */ + @Test + void anOldSnapshotCallerDoesNotDuplicateTheNewSnapshotsDiscovery() throws Exception { + try (var gate = new GatedOllama()) { + ConfigSnapshot before = snapshot(ollamaAt(gate.baseUrl("/a")), null); + current.set(before); + var catalog = new ChatModelCatalog(registry); + var firstBefore = new Caller(catalog, null); + gate.awaitArrival("/a"); + + use(ollamaAt(gate.baseUrl("/b")), null); + var firstAfter = new Caller(catalog, null); + gate.awaitArrival("/b"); + + var lateBefore = new Caller(catalog, before); + lateBefore.awaitParked(); + var secondAfter = new Caller(catalog, null); + secondAfter.awaitParked(); + gate.release(); + + assertThat(secondAfter.view()).isSameAs(firstAfter.view()); + assertThat(lateBefore.view()).isSameAs(firstBefore.view()); + assertThat(gate.listings("/b")).isEqualTo(1); + assertThat(gate.listings("/a")).isEqualTo(1); + assertThat(catalog.contains("PRIMARY", "model-a")).isTrue(); + } + } + + /** + * A caller interrupted mid-discovery gets the interrupted view for itself, with its interrupt + * flag still set; nobody else does. The caller waiting on it asks again, and the view the cache + * holds is that second, uninterrupted one. + */ + @Test + void anInterruptedDiscoveryIsNeitherCachedNorHandedToItsWaiters() throws Exception { + try (var gate = new GatedOllama()) { + use(ollamaAt(gate.baseUrl("/a")), null); + var catalog = new ChatModelCatalog(registry); + var interrupted = new Caller(catalog, null); + gate.awaitArrival("/a"); + var waiter = new Caller(catalog, null); + waiter.awaitParked(); + + interrupted.thread.interrupt(); + + ConnectionView own = interrupted.view().connections().get(0); + assertThat(own.reachable()).isFalse(); + assertThat(own.reason()).isEqualTo("the listing was interrupted"); + assertThat(interrupted.interruptedAfter).isTrue(); + assertThat(catalog.contains("PRIMARY", "model-a")).isFalse(); + + // The waiter's own discovery: a second request to the primary, held like the first. + gate.awaitArrival("/a"); + assertThat(waiter.done()).isFalse(); + gate.release(); + + ConnectionView shared = waiter.view().connections().get(0); + assertThat(shared.reachable()).isTrue(); + assertThat(shared.models()).extracting(ModelView::name).containsExactly("model-a"); + assertThat(catalog.contains("PRIMARY", "model-a")).isTrue(); + catalog.models(); + assertThat(gate.listings("/a")).isEqualTo(2); + } + } + + /** + * A key with a control character in it cannot go into a header. That is its own reason, decided + * before any request is built, and no part of the key reaches the log — the HTTP client's own + * refusal would have quoted the whole header value. + */ + @Test + void anApiKeyWithAControlCharacterIsInvalidAndNeverLogged() { + var anthropic = new PipelineConfig.Llm( + ChatModels.ANTHROPIC, + SECOND.baseUrl(), + "primary-leak-canary\n", + null, + false, + new PipelineConfig.Llm.Models(null, "model-z", null, null, null, null, null, "model-x"), + null); + var compatible = new PipelineConfig.ChatConnection( + ChatModels.OPENAI_COMPATIBLE, SECOND.baseUrl() + "/v1", "second-leak-canary\r", null, null, null); + use(anthropic, compatible); + Logger root = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + ListAppender logged = new ListAppender<>(); + logged.start(); + root.addAppender(logged); + ChatModelsView view; + try { + view = new ChatModelCatalog(registry).models(); + } finally { + root.detachAppender(logged); + } + + assertThat(view.connections()) + .extracting(ConnectionView::reason) + .containsExactly( + "the API key of this connection is not valid", "the API key of this connection is not valid"); + assertThat(SECOND.getAllServeEvents()).isEmpty(); + assertThat(logged.list).isNotEmpty(); + for (ILoggingEvent event : logged.list) { + assertThat(event.getFormattedMessage()).doesNotContain("leak-canary"); + if (event.getThrowableProxy() != null) { + assertThat(ThrowableProxyUtil.asString(event.getThrowableProxy())) + .doesNotContain("leak-canary"); + } + } + } + + /** + * The reason a redirect is not followed is the key it would carry along. Ollama's listing carries + * none, so behind a proxy that moves it, it is followed and listed; a keyed listing still is not. + */ + @Test + void aKeylessListingFollowsARedirectAndAKeyedOneDoesNot() { + PRIMARY.stubFor(get(urlPathEqualTo("/api/tags")) + .willReturn(aResponse().withStatus(301).withHeader("Location", PRIMARY.baseUrl() + "/moved/api/tags"))); + PRIMARY.stubFor( + get(urlPathEqualTo("/moved/api/tags")).willReturn(okJson("{\"models\":[{\"name\":\"model-a\"}]}"))); + SECOND.resetAll(); + SECOND.stubFor(get(urlPathEqualTo("/v1/models")) + .willReturn(aResponse().withStatus(301).withHeader("Location", SECOND.baseUrl() + "/elsewhere"))); + SECOND.stubFor(get(urlPathEqualTo("/elsewhere")).willReturn(okJson("{\"data\":[{\"id\":\"model-x\"}]}"))); + use(ollamaPrimary(), hostedSecond()); + + ChatModelsView view = new ChatModelCatalog(registry).models(); + + ConnectionView primary = view.connections().get(0); + assertThat(primary.reachable()).isTrue(); + assertThat(primary.models()).extracting(ModelView::name).containsExactly("model-a"); + ConnectionView second = view.connections().get(1); + assertThat(second.reachable()).isFalse(); + assertThat(second.reason()).isEqualTo("the model list answered with a redirect"); + SECOND.verify(0, getRequestedFor(urlPathEqualTo("/elsewhere"))); + } + + /** A connection without a provider kind cannot be listed, and nobody is asked. */ + @Test + void aConnectionWithoutAProviderKindIsUnlistable() { + var kindless = new PipelineConfig.Llm( + null, + PRIMARY.baseUrl(), + null, + null, + false, + new PipelineConfig.Llm.Models(null, "model-z", null, null, null, null, null, "model-a"), + null); + use(kindless, null); + + ConnectionView primary = + new ChatModelCatalog(registry).models().connections().get(0); + + assertThat(primary.reachable()).isFalse(); + assertThat(primary.reason()).isEqualTo("this provider kind cannot list its models"); + assertThat(PRIMARY.getAllServeEvents()).isEmpty(); + } + + /** + * A caller waiting on someone else's discovery can be interrupted: it gets the interrupted view + * for itself, with its flag still set, and the discovery it waited on goes on and is cached. + */ + @Test + void anInterruptedWaiterGetsItsOwnInterruptedViewAndTheDiscoveryGoesOn() throws Exception { + try (var gate = new GatedOllama()) { + use(ollamaAt(gate.baseUrl("/a")), null); + var catalog = new ChatModelCatalog(registry); + var running = new Caller(catalog, null); + gate.awaitArrival("/a"); + var waiter = new Caller(catalog, null); + waiter.awaitParked(); + + waiter.thread.interrupt(); + + ConnectionView own = waiter.view().connections().get(0); + assertThat(own.reachable()).isFalse(); + assertThat(own.reason()).isEqualTo("the listing was interrupted"); + assertThat(waiter.interruptedAfter).isTrue(); + assertThat(running.done()).isFalse(); + assertThat(catalog.contains("PRIMARY", "model-a")).isFalse(); + + gate.release(); + + assertThat(running.view().connections().get(0).reachable()).isTrue(); + assertThat(catalog.contains("PRIMARY", "model-a")).isTrue(); + assertThat(gate.listings("/a")).isEqualTo(1); + } + } + + /** + * A waiter whose shared discovery was interrupted asks again under the configuration current + * then, not the one it first read: a reload in between is what it lists. + */ + @Test + void aWaiterAskingAgainReadsTheCurrentConfiguration() throws Exception { + try (var gate = new GatedOllama()) { + use(ollamaAt(gate.baseUrl("/a")), null); + var catalog = new ChatModelCatalog(registry); + var interrupted = new Caller(catalog, null); + gate.awaitArrival("/a"); + var waiter = new Caller(catalog, null); + waiter.awaitParked(); + + use(ollamaAt(gate.baseUrl("/b")), null); + interrupted.thread.interrupt(); + interrupted.view(); + + gate.awaitArrival("/b"); + gate.release(); + + assertThat(waiter.view().connections().get(0).reachable()).isTrue(); + assertThat(gate.listings("/a")).isEqualTo(1); + assertThat(gate.listings("/b")).isEqualTo(1); + assertThat(catalog.contains("PRIMARY", "model-a")).isTrue(); + } + } + + private static void ollamaListsThreeModelsOneWithoutTools() { + PRIMARY.stubFor(get(urlPathEqualTo("/api/tags")) + .willReturn( + okJson("{\"models\":[{\"name\":\"model-a\"},{\"name\":\"model-b\"},{\"name\":\"model-c\"}]}"))); + for (String name : List.of("model-a", "model-b")) { + PRIMARY.stubFor(post(urlPathEqualTo("/api/show")) + .withRequestBody(matchingJsonPath("$.model", equalTo(name))) + .willReturn(okJson("{\"capabilities\":[\"completion\",\"tools\"]}"))); + } + PRIMARY.stubFor(post(urlPathEqualTo("/api/show")) + .withRequestBody(matchingJsonPath("$.model", equalTo("model-c"))) + .willReturn(okJson("{\"capabilities\":[\"completion\"]}"))); + } + + private static void hostedListsTwoModelsOverTwoPages() { + SECOND.stubFor(get(urlPathEqualTo("/v1/models")) + .withQueryParam("after_id", absent()) + .willReturn(okJson("{\"data\":[{\"id\":\"model-x\",\"type\":\"model\"}]," + + "\"has_more\":true,\"first_id\":\"model-x\",\"last_id\":\"model-x\"}"))); + SECOND.stubFor(get(urlPathEqualTo("/v1/models")) + .withQueryParam("after_id", equalTo("model-x")) + .willReturn(okJson("{\"data\":[{\"id\":\"model-y\",\"type\":\"model\"}]," + + "\"has_more\":false,\"first_id\":\"model-y\",\"last_id\":\"model-y\"}"))); + } + + /** Ollama's chat path is the OpenAI-compatible one under {@code /v1/}; the catalog reads the native root. */ + private static PipelineConfig.Llm ollamaPrimary() { + return new PipelineConfig.Llm( + ChatModels.OLLAMA, + PRIMARY.baseUrl() + "/v1/", + null, + null, + false, + new PipelineConfig.Llm.Models(null, "model-z", null, null, null, null, null, "model-a"), + null); + } + + private static PipelineConfig.Llm ollamaAt(String baseUrl) { + return new PipelineConfig.Llm( + ChatModels.OLLAMA, + baseUrl, + null, + null, + false, + new PipelineConfig.Llm.Models(null, "model-z", null, null, null, null, null, "model-a"), + null); + } + + private static PipelineConfig.Llm hostedPrimary() { + return new PipelineConfig.Llm( + ChatModels.ANTHROPIC, + SECOND.baseUrl(), + "primary-key", + null, + false, + new PipelineConfig.Llm.Models(null, "model-z", null, null, null, null, null, "model-x"), + null); + } + + private static PipelineConfig.ChatConnection hostedSecond() { + return new PipelineConfig.ChatConnection( + ChatModels.ANTHROPIC, SECOND.baseUrl(), "second-key", null, null, null); + } + + /** A fresh snapshot object, as a reload would hand out, served from now on. */ + private void use(PipelineConfig.Llm llm, PipelineConfig.ChatConnection second) { + current.set(snapshot(llm, second)); + } + + private static ConfigSnapshot snapshot(PipelineConfig.Llm llm, PipelineConfig.ChatConnection second) { + var pipeline = Mockito.mock(PipelineConfig.class); + given(pipeline.llm()).willReturn(llm); + given(pipeline.chat()).willReturn(new PipelineConfig.Chat(null, null, null, second)); + return new ConfigSnapshot(pipeline, null, null, null, null, null); + } + + /** + * One caller of {@link ChatModelCatalog#models()} on a thread of its own, reading {@code pinned} + * as its snapshot when one is given and the registry's current one otherwise. + */ + private static final class Caller { + + private final CompletableFuture view = new CompletableFuture<>(); + private final Thread thread; + private volatile boolean interruptedAfter; + + Caller(ChatModelCatalog catalog, ConfigSnapshot pinned) { + thread = Thread.ofPlatform().daemon().start(() -> { + if (pinned != null) { + PINNED.set(pinned); + } + try { + ChatModelsView answer = catalog.models(); + interruptedAfter = Thread.currentThread().isInterrupted(); + view.complete(answer); + } catch (Throwable e) { + view.completeExceptionally(e); + } + }); + } + + ChatModelsView view() throws Exception { + return view.get(HANG.toSeconds(), TimeUnit.SECONDS); + } + + boolean done() { + return view.isDone(); + } + + /** + * Returns once the caller is parked on a discovery someone else is running — waiting, inside + * the catalog's {@code await}, on the in-flight future's {@code get} — and fails if it answered + * instead. A caller blocked anywhere else (its own discovery's HTTP request, a class being + * loaded) does not count. Watches the thread's state and stack, never the clock; {@link #HANG} + * only bounds a hang. + */ + void awaitParked() { + long giveUp = System.nanoTime() + HANG.toNanos(); + while (!parkedOnSharedDiscovery()) { + assertThat(view.isDone()) + .as("the caller answered instead of waiting") + .isFalse(); + assertThat(System.nanoTime()) + .as("the caller never parked on the shared discovery") + .isLessThan(giveUp); + Thread.onSpinWait(); + } + } + + /** + * Whether the thread is waiting and one snapshot of its stack has it parked — the top frame a + * {@code park} — inside {@code CompletableFuture.get} called from {@code ChatModelCatalog.await}: + * the future another caller completes. The park on top ties the snapshot to that very wait, so + * a thread that waited elsewhere a moment before or after does not pass; once it is in that + * frame it has joined the shared discovery. + */ + private boolean parkedOnSharedDiscovery() { + Thread.State state = thread.getState(); + if (state != Thread.State.WAITING && state != Thread.State.TIMED_WAITING) { + return false; + } + StackTraceElement[] stack = thread.getStackTrace(); + if (stack.length == 0 || !stack[0].getMethodName().equals("park")) { + return false; + } + for (int i = 0; i < stack.length; i++) { + if (stack[i].getClassName().equals(ChatModelCatalog.class.getName()) + && stack[i].getMethodName().equals("await")) { + // Every frame above the catalog's await is what it called into, so the future's + // get has to be among them; a park anywhere else is not this wait. + for (int j = 0; j < i; j++) { + if (stack[j].getClassName().equals(CompletableFuture.class.getName()) + && (stack[j].getMethodName().equals("get") + || stack[j].getMethodName().equals("waitingGet"))) { + return true; + } + } + return false; + } + } + return false; + } + } + + /** + * An Ollama server whose model lists ({@code /api/tags}) are held until {@link #release()}, + * counted per base-URL prefix, and announced as they arrive, so a test knows a discovery is in + * flight without waiting on the clock. Each lists {@code model-a}, which can call tools. + */ + private static final class GatedOllama implements AutoCloseable { + + private final HttpServer server; + private final CountDownLatch gate = new CountDownLatch(1); + private final ConcurrentMap arrived = new ConcurrentHashMap<>(); + private final ConcurrentMap listings = new ConcurrentHashMap<>(); + + GatedOllama() throws IOException { + server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); + server.createContext("/", this::handle); + server.start(); + } + + /** The base URL a primary on {@code prefix} is configured with, OpenAI-compatible path and all. */ + String baseUrl(String prefix) { + return "http://" + server.getAddress().getHostString() + ":" + + server.getAddress().getPort() + prefix + "/v1/"; + } + + void awaitArrival(String prefix) throws InterruptedException { + assertThat(arrived(prefix).tryAcquire(HANG.toSeconds(), TimeUnit.SECONDS)) + .as("a model list request on %s", prefix) + .isTrue(); + } + + void release() { + gate.countDown(); + } + + int listings(String prefix) { + return listings.computeIfAbsent(prefix, _ -> new AtomicInteger()).get(); + } + + private Semaphore arrived(String prefix) { + return arrived.computeIfAbsent(prefix, _ -> new Semaphore(0)); + } + + private void handle(HttpExchange exchange) throws IOException { + String path = exchange.getRequestURI().getPath(); + String prefix = path.substring(0, path.indexOf("/api/")); + String body; + if (path.endsWith("/api/tags")) { + listings.computeIfAbsent(prefix, _ -> new AtomicInteger()).incrementAndGet(); + arrived(prefix).release(); + try { + gate.await(HANG.toSeconds(), TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + body = "{\"models\":[{\"name\":\"model-a\"}]}"; + } else { + body = "{\"capabilities\":[\"completion\",\"tools\"]}"; + } + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + try (OutputStream out = exchange.getResponseBody()) { + exchange.sendResponseHeaders(200, bytes.length); + out.write(bytes); + } catch (IOException gone) { + // The client stopped waiting — the interrupted caller's request. + } + } + + @Override + public void close() { + gate.countDown(); + server.stop(0); + } + } +} diff --git a/backend/src/test/java/de/codeministry/leadgen/chat/ChatModelSelectTest.java b/backend/src/test/java/de/codeministry/leadgen/chat/ChatModelSelectTest.java new file mode 100644 index 0000000..edacf0a --- /dev/null +++ b/backend/src/test/java/de/codeministry/leadgen/chat/ChatModelSelectTest.java @@ -0,0 +1,517 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.codeministry.leadgen.Databases; +import de.codeministry.leadgen.config.ConfigFixtures; +import de.codeministry.leadgen.config.ConfigRegistry; +import de.codeministry.leadgen.config.Secrets; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * ISC-507: a turn is answered by exactly the model and connection it was asked with, and a turn + * asked without one by the default on the primary connection. + * + *

Two stubs, one per connection, each counting what it received, so "answered at the second + * stub only" is read off the wire rather than off a row. The {@code turn} event and the reloaded + * conversation are read over real HTTP, as the drawer reads them. One case puts a Messages-API + * stub behind the second connection, because that kind builds a different client. + * + *

ISC-508: the model may change between turns, and the next one reads the conversation as the + * earlier turns left it. ISC-509: a model that fails, times out or is refused ends its turn there, + * and the other connection never hears of it — counted at the other stub, in both directions. + * + *

ISC-513: the suggestions, the follow-ups and the semantic tool's query vector stay on the + * primary connection whichever model a turn uses. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +class ChatModelSelectTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + private static final ModelStub PRIMARY = ModelStub.start(); + private static final ModelStub SECOND = ModelStub.start(); + private static final AnthropicStub HOSTED = AnthropicStub.start(); + + /** A secret property the masker knows, planted in an offer a tool returns, as {@code ChatRedactionTest} does. */ + private static final String SECRET = "Xv9-select-Secret-Value"; + + /** The embedding model one case switches retrieval on over, on the primary connection. */ + private static final String EMBEDDING = "test-embed"; + + /** Created once, never inside the property supplier — see {@code LlmBudgetTest}. */ + private static final Path CONFIG_DIRECTORY = freshConfigDirectory(); + + @Container + @ServiceConnection + static final PostgreSQLContainer POSTGRES = Databases.postgres(); + + @DynamicPropertySource + static void configuration(DynamicPropertyRegistry registry) { + registry.add("leadgen.config-dir", CONFIG_DIRECTORY::toString); + registry.add("leadgen.test.client-secret", () -> SECRET); + } + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbc; + + @Autowired + private ConfigRegistry config; + + @AfterAll + static void stop() { + PRIMARY.close(); + SECOND.close(); + HOSTED.close(); + } + + @BeforeEach + void reset() { + PRIMARY.reset(); + SECOND.reset(); + HOSTED.reset(); + PRIMARY.lists(ModelStub.MODEL, "model-a"); + SECOND.lists("model-b"); + jdbc.update("DELETE FROM chat_conversation"); + jdbc.update("DELETE FROM chat_call_budget"); + jdbc.update("DELETE FROM chat_connection_call_budget"); + secondConnection("openai-compatible", SECOND.baseUrl()); + } + + @Test + void aPickedModelIsAnsweredOnItsConnectionAndNoPickOnThePrimaryWithTheDefault() { + assertThat(get("/api/v1/chat/models").path("connections")).hasSize(2); + long conversation = conversation(); + + SECOND.enqueue(ModelStub.text(Duration.ZERO, "From the second.")); + List picked = TurnStream.post( + port, + "/api/v1/chat/conversations/" + conversation + "/turns", + "{\"question\":\"Which connection?\",\"connection\":\"SECOND\",\"model\":\"model-b\"}", + event -> {}); + + assertThat(TurnStream.names(picked).getLast()).isEqualTo("done"); + assertThat(TurnStream.text(picked)).isEqualTo("From the second."); + assertThat(SECOND.bodies()) + .singleElement() + .satisfies(body -> + assertThat(JSON.readTree(body).path("model").asText()).isEqualTo("model-b")); + assertThat(PRIMARY.bodies()).as("nothing asked at the primary").isEmpty(); + assertThat(turnEvent(picked)).containsExactly("model-b", "SECOND"); + + PRIMARY.enqueue(ModelStub.text(Duration.ZERO, "From the primary.")); + List unpicked = TurnStream.ask(port, conversation, "And by default?"); + + assertThat(TurnStream.names(unpicked).getLast()).isEqualTo("done"); + assertThat(TurnStream.text(unpicked)).isEqualTo("From the primary."); + assertThat(PRIMARY.bodies()) + .singleElement() + .satisfies(body -> + assertThat(JSON.readTree(body).path("model").asText()).isEqualTo(ModelStub.MODEL)); + assertThat(SECOND.bodies()).as("the second asked once, by the pick").hasSize(1); + assertThat(turnEvent(unpicked)).containsExactly(ModelStub.MODEL, "PRIMARY"); + + JsonNode turns = get("/api/v1/chat/conversations/" + conversation).path("turns"); + assertThat(turns).hasSize(2); + assertThat(List.of( + turns.get(0).path("model").asText(), + turns.get(0).path("connection").asText())) + .containsExactly("model-b", "SECOND"); + assertThat(List.of( + turns.get(1).path("model").asText(), + turns.get(1).path("connection").asText())) + .containsExactly(ModelStub.MODEL, "PRIMARY"); + } + + /** + * The second connection as the Messages API. No list is read before the turn: the controller + * reads it once itself rather than refusing a pick it simply had not looked up yet. + */ + @Test + void aPickOnAMessagesApiSecondConnectionIsAnsweredThere() { + secondConnection("anthropic", HOSTED.baseUrl()); + HOSTED.lists("model-b"); + HOSTED.enqueueText("From the hosted ", "model."); + long conversation = conversation(); + + List events = TurnStream.post( + port, + "/api/v1/chat/conversations/" + conversation + "/turns", + "{\"question\":\"Which connection?\",\"connection\":\"SECOND\",\"model\":\"model-b\"}", + event -> {}); + + assertThat(TurnStream.names(events).getLast()).isEqualTo("done"); + assertThat(TurnStream.text(events)).isEqualTo("From the hosted model."); + assertThat(HOSTED.listings()).as("the list read once, by the check").isEqualTo(1); + assertThat(HOSTED.paths()).containsExactly("/v1/messages"); + assertThat(HOSTED.bodies()) + .singleElement() + .satisfies(body -> + assertThat(JSON.readTree(body).path("model").asText()).isEqualTo("model-b")); + assertThat(PRIMARY.bodies()).isEmpty(); + assertThat(SECOND.bodies()).isEmpty(); + assertThat(turnEvent(events)).containsExactly("model-b", "SECOND"); + assertThat(jdbc.queryForList("SELECT model || ' ' || connection FROM chat_turn", String.class)) + .containsExactly("model-b SECOND"); + } + + /** + * ISC-508: turn 1 on the primary with a tool round and a citation, turn 2 on the second + * connection, then turn 2 regenerated on the primary. The second connection reads the + * conversation as turn 1 left it — the question, the answer with its citation as a marker, no + * planted secret — and the regenerate is a turn of its own, naming the primary's model, while + * the replaced turn keeps the second's. + */ + @Test + void theNextModelReadsTheConversationAsTheEarlierTurnsLeftItAndARegenerateKeepsTheReplaced() { + long offer = plantedOffer(); + long conversation = conversation(); + PRIMARY.enqueue(ModelStub.toolCalls("search_offers", "{\"text\":\"kafka\"}")); + PRIMARY.enqueue(ModelStub.text(Duration.ZERO, "The Kafka role ", "[[offer:" + offer + "]].")); + + List first = TurnStream.ask(port, conversation, "Which Kafka roles are open?"); + + assertThat(TurnStream.names(first).getLast()).isEqualTo("done"); + assertThat(PRIMARY.bodies()).hasSize(2); + assertThat(PRIMARY.bodies().getLast()) + .as("turn 1's tool result reached the primary masked") + .contains(Secrets.MASK) + .doesNotContain(SECRET); + + SECOND.enqueue(ModelStub.text(Duration.ZERO, "From the second.")); + List second = TurnStream.post( + port, + "/api/v1/chat/conversations/" + conversation + "/turns", + "{\"question\":\"And the rate?\",\"connection\":\"SECOND\",\"model\":\"model-b\"}", + event -> {}); + + assertThat(TurnStream.names(second).getLast()).isEqualTo("done"); + String body = SECOND.bodies().getFirst(); + assertThat(SECOND.bodies()).hasSize(1); + assertThat(roles(body)).containsExactly("system", "user", "assistant", "user"); + assertThat(contents(body).subList(1, 4)) + .containsExactly( + "Which Kafka roles are open?", "The Kafka role [[offer:" + offer + "]].", "And the rate?"); + assertThat(body).as("no planted secret in the history").doesNotContain(SECRET); + long replaced = Long.parseLong(TurnStream.JsonText.field(turn(second).data(), "turnId")); + + PRIMARY.enqueue(ModelStub.text(Duration.ZERO, "From the primary again.")); + List again = TurnStream.post( + port, + "/api/v1/chat/conversations/" + conversation + "/turns/" + replaced + "/regenerate", + "{\"connection\":\"PRIMARY\",\"model\":\"" + ModelStub.MODEL + "\"}", + event -> {}); + + assertThat(TurnStream.names(again).getLast()).isEqualTo("done"); + assertThat(turnEvent(again)).containsExactly(ModelStub.MODEL, "PRIMARY"); + assertThat(PRIMARY.bodies()).hasSize(3); + assertThat(contents(PRIMARY.bodies().getLast()).subList(1, 4)) + .as("the regenerate reads turn 1 as it was left") + .containsExactly( + "Which Kafka roles are open?", "The Kafka role [[offer:" + offer + "]].", "And the rate?"); + long regenerated = Long.parseLong(TurnStream.JsonText.field(turn(again).data(), "turnId")); + assertThat(regenerated).isNotEqualTo(replaced); + assertThat(jdbc.queryForList( + "SELECT id || ' ' || model || ' ' || connection || ' ' || coalesce(replaces_turn_id::text, '-')" + + " FROM chat_turn WHERE conversation_id = ? ORDER BY id", + String.class, + conversation)) + .as("three turns; the replaced one still names the second") + .hasSize(3) + .endsWith(replaced + " model-b SECOND -", regenerated + " " + ModelStub.MODEL + " PRIMARY " + replaced); + } + + /** + * ISC-509: a turn on the second connection whose model answers 500, then times out, then is + * refused by its own ceiling, ends with {@code MODEL}, {@code MODEL} and {@code BUDGET} — and + * never asks the primary. + */ + @Test + void aFailingSecondConnectionEndsItsTurnAndNeverFallsBackToThePrimary() { + timeouts("PT2S"); + + List reasons = new ArrayList<>(); + // No reply queued: the stub answers 500. + reasons.add(failed(pickedTurn("SECOND", "model-b"))); + SECOND.enqueue(ModelStub.text(Duration.ofSeconds(10), "Too late.")); + reasons.add(failed(pickedTurn("SECOND", "model-b"))); + secondCeiling("0"); + reasons.add(failed(pickedTurn("SECOND", "model-b"))); + + assertThat(reasons).containsExactly("MODEL", "MODEL", "BUDGET"); + assertThat(SECOND.bodies()) + .as("the 500 and the timeout, nothing once refused") + .hasSize(2); + assertThat(PRIMARY.bodies()).as("requests at the primary stub").isEmpty(); + assertThat(jdbc.queryForList("SELECT state FROM chat_turn", String.class)) + .containsExactly("INCOMPLETE", "INCOMPLETE", "INCOMPLETE"); + } + + /** ISC-509, swapped: the same three failures on the primary never reach the second connection. */ + @Test + void aFailingPrimaryEndsItsTurnAndNeverFallsBackToTheSecondConnection() { + timeouts("PT2S"); + + List reasons = new ArrayList<>(); + reasons.add(failed(pickedTurn("PRIMARY", ModelStub.MODEL))); + PRIMARY.enqueue(ModelStub.text(Duration.ofSeconds(10), "Too late.")); + reasons.add(failed(pickedTurn("PRIMARY", ModelStub.MODEL))); + rewrite(text -> text.replaceFirst("(?m)^ max_calls_per_day: .*$", " max_calls_per_day: 0")); + reasons.add(failed(pickedTurn("PRIMARY", ModelStub.MODEL))); + + assertThat(reasons).containsExactly("MODEL", "MODEL", "BUDGET"); + assertThat(PRIMARY.bodies()) + .as("the 500 and the timeout, nothing once refused") + .hasSize(2); + assertThat(SECOND.bodies()).as("requests at the second stub").isEmpty(); + } + + /** + * ISC-513: a turn on the second connection that calls {@code search_by_meaning}, then its + * follow-ups and the empty chat's suggestions. Retrieval is switched on over an embedding model + * on the primary, so the tool really asks for a query vector; the primary stub answers that + * request 500, which the tool reports as unreadable words — what is counted is where it went. + * Everything but the turn's own two rounds arrives at the primary stub, nothing else at the + * second. + */ + @Test + void suggestionsFollowUpsAndTheQueryVectorStayOnThePrimaryWhicheverModelATurnUses() { + rewrite(text -> text.replaceFirst( + "(?m)^(\\s*)embedding: \\$\\{LLM_MODEL_EMBEDDING}.*$", "$1embedding: " + EMBEDDING) + // By its placeholder: `enabled:` appears on several blocks in this file. + .replace("${RETRIEVAL_ENABLED:false}", "true")); + long offer = plantedOffer(); + long conversation = conversation(); + SECOND.enqueue(ModelStub.toolCalls( + "search_by_meaning", "{\"query\":\"event streaming\"}", "search_offers", "{\"text\":\"kafka\"}")); + SECOND.enqueue(ModelStub.text(Duration.ZERO, "The Kafka role ", "[[offer:" + offer + "]].")); + + List events = TurnStream.post( + port, + "/api/v1/chat/conversations/" + conversation + "/turns", + "{\"question\":\"Anything like event streaming?\",\"connection\":\"SECOND\",\"model\":\"model-b\"}", + event -> {}); + + assertThat(PRIMARY.bodies()) + .as("the query vector, asked at the primary") + .singleElement() + .satisfies(body -> { + JsonNode request = JSON.readTree(body); + assertThat(request.path("model").asText()).isEqualTo(EMBEDDING); + assertThat(request.path("input").toString()).contains("event streaming"); + }); + assertThat(SECOND.bodies()) + .as("the turn's two rounds and nothing else at the second") + .hasSize(2) + .allSatisfy(body -> + assertThat(JSON.readTree(body).path("model").asText()).isEqualTo("model-b")); + assertThat(TurnStream.names(events).getLast()).isEqualTo("done"); + long turn = Long.parseLong(TurnStream.JsonText.field(turn(events).data(), "turnId")); + + PRIMARY.enqueue(ModelStub.text(Duration.ZERO, "Phrased.")); + PRIMARY.enqueue(ModelStub.text(Duration.ZERO, "Phrased.")); + assertThat(get("/api/v1/chat/conversations/" + conversation + "/turns/" + turn + "/followups")) + .hasSizeGreaterThanOrEqualTo(2); + assertThat(get("/api/v1/chat/suggestions?context=o:" + offer)).isNotEmpty(); + + assertThat(PRIMARY.bodies()) + .as("the vector, then the follow-ups and the suggestions phrased at the primary") + .hasSize(3); + assertThat(PRIMARY.bodies().subList(1, 3)) + .allSatisfy(body -> + assertThat(JSON.readTree(body).path("model").asText()).isEqualTo(ModelStub.MODEL)); + assertThat(SECOND.bodies()).as("still the turn's two rounds alone").hasSize(2); + } + + /** One turn in a fresh conversation, asked of {@code model} on {@code connection}. */ + private List pickedTurn(String connection, String model) { + return TurnStream.post( + port, + "/api/v1/chat/conversations/" + conversation() + "/turns", + "{\"question\":\"Anything?\",\"connection\":\"" + connection + "\",\"model\":\"" + model + "\"}", + event -> {}); + } + + /** The reason of the turn's {@code error} event; fails when the turn ended any other way. */ + private static String failed(List events) { + TurnStream.Event last = events.getLast(); + assertThat(last.name()).as("the turn's last event").isEqualTo("error"); + return TurnStream.JsonText.field(last.data(), "reason"); + } + + /** An offer {@code search_offers} finds for "kafka", carrying the planted secret in its title and text. */ + private long plantedOffer() { + long source = jdbc.queryForObject( + "INSERT INTO source (name, kind) VALUES ('select', 'file') ON CONFLICT (name) DO UPDATE" + + " SET kind = excluded.kind RETURNING id", + Long.class); + jdbc.update("DELETE FROM offer WHERE external_id = 'select-1'"); + String planted = "Ask for " + SECRET; + return jdbc.queryForObject(""" + INSERT INTO offer (source_id, external_id, title, description, url, fingerprint, status, + score_value, portal, ingested_at) + VALUES (?, 'select-1', ?, ?, 'https://example.invalid/select', 'select-1', 'PASSED', 80, + 'portal-a', now()) + RETURNING id + """, Long.class, source, "Kafka Developer · " + planted, planted); + } + + /** The {@code role} of each message in a request body. */ + private static List roles(String body) { + return messages(body).stream() + .map(message -> message.path("role").asText()) + .toList(); + } + + /** The {@code content} of each message in a request body. */ + private static List contents(String body) { + return messages(body).stream() + .map(message -> message.path("content").asText()) + .toList(); + } + + private static List messages(String body) { + try { + List messages = new ArrayList<>(); + JSON.readTree(body).path("messages").forEach(messages::add); + return messages; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** Both connections' request timeout, so a model that never answers fails in seconds. */ + private void timeouts(String timeout) { + rewrite(text -> text.replaceFirst("(?m)^ timeout: .*$", " timeout: " + timeout) + .replaceFirst("(?m)^ timeout: \\$\\{CHAT_LLM_TIMEOUT.*$", " timeout: " + timeout)); + } + + /** {@code chat.connection.max_calls_per_day}. */ + private void secondCeiling(String value) { + rewrite(text -> text.replaceFirst( + "(?m)^ max_calls_per_day: \\$\\{CHAT_LLM_MAX_CALLS_PER_DAY.*$", " max_calls_per_day: " + value)); + } + + private void rewrite(UnaryOperator edit) { + Path pipeline = CONFIG_DIRECTORY.resolve("pipeline.yaml"); + try { + String before = Files.readString(pipeline, StandardCharsets.UTF_8); + String after = edit.apply(before); + assertThat(after).as("the rewrite matched").isNotEqualTo(before); + Files.writeString(pipeline, after, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + config.reload(); + } + + /** The {@code turn} event. */ + private static TurnStream.Event turn(List events) { + return events.stream() + .filter(event -> event.name().equals("turn")) + .findFirst() + .orElseThrow(); + } + + /** The {@code turn} event's model and connection. */ + private static List turnEvent(List events) { + TurnStream.Event turn = events.stream() + .filter(event -> event.name().equals("turn")) + .findFirst() + .orElseThrow(); + return List.of( + TurnStream.JsonText.field(turn.data(), "model"), TurnStream.JsonText.field(turn.data(), "connection")); + } + + private long conversation() { + return jdbc.queryForObject("INSERT INTO chat_conversation (title) VALUES ('') RETURNING id", Long.class); + } + + private JsonNode get(String path) { + HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path)) + .timeout(Duration.ofSeconds(30)) + .GET() + .build(); + try (HttpClient client = HttpClient.newHttpClient()) { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + assertThat(response.statusCode()).as(path).isEqualTo(200); + return JSON.readTree(response.body()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + /** The shipped files, the primary pointed at its stub and {@code chat.connection} at {@code baseUrl}. */ + private void secondConnection(String provider, String baseUrl) { + ConfigFixtures.materialize(CONFIG_DIRECTORY); + Path pipeline = CONFIG_DIRECTORY.resolve("pipeline.yaml"); + try { + String shipped = Files.readString(pipeline, StandardCharsets.UTF_8); + Files.writeString( + pipeline, + ModelStub.pointSecondAt(ModelStub.pointLlmAt(shipped, PRIMARY.baseUrl()), provider, baseUrl), + StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + config.reload(); + } + + private static Path freshConfigDirectory() { + try { + Path dir = Files.createTempDirectory("leadgen-chat-model-select"); + dir.toFile().deleteOnExit(); + Path materialized = ConfigFixtures.materialize(dir); + Path pipeline = materialized.resolve("pipeline.yaml"); + Files.writeString( + pipeline, + ModelStub.pointLlmAt(Files.readString(pipeline, StandardCharsets.UTF_8), PRIMARY.baseUrl()), + StandardCharsets.UTF_8); + return materialized; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/backend/src/test/java/de/codeministry/leadgen/chat/ChatRedactionTest.java b/backend/src/test/java/de/codeministry/leadgen/chat/ChatRedactionTest.java index 7798632..80ee525 100644 --- a/backend/src/test/java/de/codeministry/leadgen/chat/ChatRedactionTest.java +++ b/backend/src/test/java/de/codeministry/leadgen/chat/ChatRedactionTest.java @@ -20,6 +20,7 @@ import java.time.Duration; import java.util.List; import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -43,6 +44,9 @@ * and advert, a profile field, an application's note and an event note — and a turn calls every * tool. What is read is the model's side of the wire: every body the stub received, system prompt, * tool definitions and tool results together. + * + *

ISC-514: the same turn asked of a model on the chat's second connection, read at the second + * stub. A hosted model means corpus text leaves the machine; the address and the secrets must not. */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Testcontainers @@ -58,6 +62,9 @@ class ChatRedactionTest { private static final ModelStub MODEL = ModelStub.start(); + /** The chat's second connection, configured beside the primary for every case (spec 025). */ + private static final ModelStub SECOND = ModelStub.start(); + /** Created once, never inside the property supplier — see {@code LlmBudgetTest}. */ private static final Path CONFIG = seeded(MODEL.configuration()); @@ -80,10 +87,56 @@ static void configuration(DynamicPropertyRegistry registry) { @AfterAll static void stop() { MODEL.close(); + SECOND.close(); + } + + /** Each case plants its own rows, so the masked count it reads is its own. */ + @BeforeEach + void reset() { + MODEL.reset(); + SECOND.reset(); + SECOND.lists("second-model"); + jdbc.update("DELETE FROM chat_conversation"); + jdbc.update("DELETE FROM offer WHERE source_id IN (SELECT id FROM source WHERE name = 'red')"); + jdbc.update("DELETE FROM source WHERE name = 'red'"); } @Test void noBodyTheModelReceivesCarriesTheAddressOrASecret() { + long conversation = plantAndScriptEveryTool(MODEL); + + List events = TurnStream.ask(port, conversation, "What is written about Kafka?"); + + assertThat(TurnStream.names(events).getLast()).isEqualTo("done"); + assertMaskedEverywhere(MODEL.bodies()); + assertThat(SECOND.bodies()).as("nothing asked on the second connection").isEmpty(); + } + + /** + * ISC-514: the turn picked onto the second connection, through every tool. Every body the + * second stub received is read, and the primary received none — so nothing the masker missed + * could have left by the other wire either. + */ + @Test + void noBodyTheSecondConnectionReceivesCarriesTheAddressOrASecret() { + long conversation = plantAndScriptEveryTool(SECOND); + + List events = TurnStream.post( + port, + "/api/v1/chat/conversations/" + conversation + "/turns", + "{\"question\":\"What is written about Kafka?\",\"connection\":\"SECOND\",\"model\":\"second-model\"}", + event -> {}); + + assertThat(TurnStream.names(events).getLast()).isEqualTo("done"); + assertMaskedEverywhere(SECOND.bodies()); + assertThat(MODEL.bodies()).as("nothing asked on the primary").isEmpty(); + } + + /** + * The planted rows, the tool round that reads them all and the answer after it, scripted at + * {@code stub}; returns a fresh conversation. + */ + private long plantAndScriptEveryTool(ModelStub stub) { String planted = "Write to " + ADDRESS + " with " + PASSWORD + " or " + ENVIRONMENT_SECRET; long source = jdbc.queryForObject("INSERT INTO source (name, kind) VALUES ('red', 'file') RETURNING id", Long.class); @@ -103,20 +156,18 @@ INSERT INTO offer (source_id, external_id, title, description, url, fingerprint, + " VALUES (?, 'PACKAGED', 'SENT', ?)", application, planted); - MODEL.enqueue(ModelStub.toolCalls( + stub.enqueue(ModelStub.toolCalls( "search_offers", "{\"text\":\"kafka\"}", "search_by_meaning", "{\"query\":\"event streaming\"}", "statistics", "{}", "application", "{\"offerId\":" + offer + "}", "profile", "{}")); - MODEL.enqueue(ModelStub.text(Duration.ofMillis(5), "Nothing ", "to see.")); - long conversation = - jdbc.queryForObject("INSERT INTO chat_conversation (title) VALUES ('') RETURNING id", Long.class); - - List events = TurnStream.ask(port, conversation, "What is written about Kafka?"); + stub.enqueue(ModelStub.text(Duration.ofMillis(5), "Nothing ", "to see.")); + return jdbc.queryForObject("INSERT INTO chat_conversation (title) VALUES ('') RETURNING id", Long.class); + } - assertThat(TurnStream.names(events).getLast()).isEqualTo("done"); - List bodies = MODEL.bodies(); + /** No planted value in any of the turn's two bodies, and the planted text read and masked. */ + private static void assertMaskedEverywhere(List bodies) { assertThat(bodies).hasSize(2); for (String body : bodies) { for (String leak : LEAKS) { @@ -132,9 +183,18 @@ INSERT INTO offer (source_id, external_id, title, description, url, fingerprint, assertThat(bodies.getLast()).contains("Reach me at " + Secrets.MASK); } - /** The mailbox's login set to the address and password, and the profile carrying both. */ + /** + * The mailbox's login set to the address and password, the profile carrying both, and the + * second connection pointed at its stub. + */ private static Path seeded(Path dir) { try { + Path pipeline = dir.resolve("pipeline.yaml"); + Files.writeString( + pipeline, + ModelStub.pointSecondAt( + Files.readString(pipeline, StandardCharsets.UTF_8), "openai-compatible", SECOND.baseUrl()), + StandardCharsets.UTF_8); rewrite( dir.resolve("sources.yaml"), "(?m)^(\\s*)username: \\$\\{IMAP_USER}.*$", diff --git a/backend/src/test/java/de/codeministry/leadgen/chat/ChatStreamQueueWaitTest.java b/backend/src/test/java/de/codeministry/leadgen/chat/ChatStreamQueueWaitTest.java index 4232786..d113a38 100644 --- a/backend/src/test/java/de/codeministry/leadgen/chat/ChatStreamQueueWaitTest.java +++ b/backend/src/test/java/de/codeministry/leadgen/chat/ChatStreamQueueWaitTest.java @@ -87,8 +87,8 @@ void aTurnThatWaitedInTheQueuePastTheGraceStillSendsItsTerminalEvent() { given(conversations.exists(1L)).willReturn(true); given(capability.model()).willReturn(Optional.of("chat-model")); willAnswer(call -> { - Consumer sink = call.getArgument(2); - Runnable finished = call.getArgument(3); + Consumer sink = call.getArgument(3); + Runnable finished = call.getArgument(4); Thread.ofVirtual().start(() -> { try { Thread.sleep(QUEUED); @@ -103,7 +103,7 @@ void aTurnThatWaitedInTheQueuePastTheGraceStillSendsItsTerminalEvent() { return null; }) .given(turns) - .start(eq(1L), anyString(), any(), any()); + .start(eq(1L), anyString(), any(ModelPick.class), any(), any()); List events = TurnStream.ask(port, 1L, "Queued?"); diff --git a/backend/src/test/java/de/codeministry/leadgen/chat/ModelStub.java b/backend/src/test/java/de/codeministry/leadgen/chat/ModelStub.java index 18d91c9..d4bdd3d 100644 --- a/backend/src/test/java/de/codeministry/leadgen/chat/ModelStub.java +++ b/backend/src/test/java/de/codeministry/leadgen/chat/ModelStub.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.Locale; import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; /** @@ -54,6 +55,8 @@ record Reply(List events, Duration pause, boolean breaksOff) {} private final List bodies = Collections.synchronizedList(new ArrayList<>()); private final AtomicReference lastChunkAt = new AtomicReference<>(); private final AtomicReference brokenAt = new AtomicReference<>(); + private final AtomicReference> listed = new AtomicReference<>(List.of()); + private final AtomicInteger listings = new AtomicInteger(); private ModelStub(ServerSocket server) { this.server = server; @@ -81,6 +84,8 @@ void reset() { bodies.clear(); lastChunkAt.set(null); brokenAt.set(null); + listed.set(List.of()); + listings.set(0); } /** Every request body the stub received, in order. */ @@ -161,11 +166,7 @@ Path configuration() { Path pipeline = dir.resolve("pipeline.yaml"); Files.writeString( pipeline, - Files.readString(pipeline, StandardCharsets.UTF_8) - .replaceAll("(?m)^(\\s*)provider:.*$", "$1provider: openai-compatible") - .replaceAll("(?m)^(\\s*)base_url:.*$", "$1base_url: " + baseUrl()) - .replaceAll("(?m)^(\\s*)api_key:.*$", "$1api_key: test-key") - .replaceAll("(?m)^(\\s*)chat: \\$\\{LLM_MODEL_CHAT}.*$", "$1chat: " + MODEL), + pointLlmAt(Files.readString(pipeline, StandardCharsets.UTF_8), baseUrl()), StandardCharsets.UTF_8); return dir; } catch (IOException e) { @@ -173,6 +174,69 @@ Path configuration() { } } + /** + * {@code yaml} with the top-level {@code llm:} block's own {@code provider}, {@code base_url} + * and {@code api_key} pointed at {@code baseUrl}, and its chat model named. + * + *

Only that block, and only at its own indentation. A regex over the whole file also + * rewrote every other {@code provider:} it met, so a second connection under {@code chat:} + * would silently have pointed at this stub too, and a test of two connections would have + * measured one. The block ends at the next line that starts in column one with a key. + */ + static String pointLlmAt(String yaml, String baseUrl) { + StringBuilder out = new StringBuilder(yaml.length() + 64); + boolean inLlm = false; + for (String line : yaml.split("\n", -1)) { + if (!line.isEmpty() && Character.isLetter(line.charAt(0))) { + inLlm = line.startsWith("llm:"); + } else if (inLlm) { + line = line.replaceFirst("^ provider:.*$", " provider: openai-compatible") + .replaceFirst("^ base_url:.*$", " base_url: " + baseUrl) + .replaceFirst("^ api_key:.*$", " api_key: test-key") + .replaceFirst("^ chat: \\$\\{LLM_MODEL_CHAT}.*$", " chat: " + MODEL); + } + out.append(line).append('\n'); + } + out.setLength(out.length() - 1); + return out.toString(); + } + + /** + * {@code yaml} with the {@code chat.connection} block's {@code provider} and {@code base_url} + * set, and its key, so that the chat's second connection answers at {@code baseUrl} (spec 025). + * Only the lines at that block's own indentation, for the reason {@link #pointLlmAt} names. + */ + static String pointSecondAt(String yaml, String provider, String baseUrl) { + StringBuilder out = new StringBuilder(yaml.length() + 64); + boolean inChat = false; + boolean inConnection = false; + for (String line : yaml.split("\n", -1)) { + if (!line.isEmpty() && Character.isLetter(line.charAt(0))) { + inChat = line.startsWith("chat:"); + inConnection = false; + } else if (inChat && line.startsWith(" ") && !line.startsWith(" ") && !line.isBlank()) { + inConnection = line.startsWith(" connection:"); + } else if (inConnection) { + line = line.replaceFirst("^ provider:.*$", " provider: " + provider) + .replaceFirst("^ base_url:.*$", " base_url: " + baseUrl) + .replaceFirst("^ api_key:.*$", " api_key: second-test-key"); + } + out.append(line).append('\n'); + } + out.setLength(out.length() - 1); + return out.toString(); + } + + /** The models {@code GET …/models} lists from now on, in the OpenAI-compatible shape. */ + void lists(String... models) { + listed.set(List.of(models)); + } + + /** How many times the model list was asked for; never counted in {@link #bodies()}. */ + int listings() { + return listings.get(); + } + @Override public void close() { try { @@ -211,9 +275,35 @@ private void serve() { private void answer(Socket socket) { try (socket) { InputStream in = new BufferedInputStream(socket.getInputStream()); - bodies.add(readBody(in)); - Reply reply = replies.poll(); + String requestLine = readLine(in); + if (requestLine.isEmpty()) { + // Connected and closed without a request — a client giving up a pooled or + // cancelled connection on a reused port. No request, so nothing to record. + return; + } + String body = readBody(in); OutputStream out = socket.getOutputStream(); + // The chat's model select reads the list; that is discovery, not a model call, so it + // neither takes a scripted reply nor counts as a request body. + if (requestLine.startsWith("GET ") && requestLine.contains("/models")) { + listings.incrementAndGet(); + StringBuilder data = new StringBuilder(); + for (String model : listed.get()) { + data.append(data.isEmpty() ? "" : ",") + .append("{\"id\":") + .append(quote(model)) + .append(",\"object\":\"model\"}"); + } + byte[] json = ("{\"object\":\"list\",\"data\":[" + data + "]}").getBytes(StandardCharsets.UTF_8); + out.write(("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + json.length + + "\r\nConnection: close\r\n\r\n") + .getBytes(StandardCharsets.US_ASCII)); + out.write(json); + out.flush(); + return; + } + bodies.add(body); + Reply reply = replies.poll(); if (reply == null) { out.write("HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" .getBytes(StandardCharsets.US_ASCII)); diff --git a/backend/src/test/java/de/codeministry/leadgen/chat/catalog/KeyedListersTest.java b/backend/src/test/java/de/codeministry/leadgen/chat/catalog/KeyedListersTest.java new file mode 100644 index 0000000..3dec37d --- /dev/null +++ b/backend/src/test/java/de/codeministry/leadgen/chat/catalog/KeyedListersTest.java @@ -0,0 +1,106 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat.catalog; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import de.codeministry.leadgen.config.model.PipelineConfig; +import de.codeministry.leadgen.llm.ChatModels; +import java.util.function.Function; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mockito; + +/** + * The listers that set a credential header enforce its precondition themselves, whoever calls + * them, and either way no request is sent. + * + *

Through the catalog a blank key never reaches a lister: {@code Providers.missing} words it + * {@code NO_KEY} first. Called directly with one, a lister refuses with an + * {@link IllegalArgumentException} whose message is fixed — never {@code Bearer null}, never an + * empty key. A key that cannot go into a header is {@code INVALID_KEY}, and a malformed base URL is + * {@code INVALID_BASE_URL} whatever the key holds, because the URI is checked first. + */ +class KeyedListersTest { + + private static final Function ANTHROPIC = AnthropicModels::new; + private static final Function COMPATIBLE = + OpenAiCompatibleModels::new; + + static Stream blankKeys() { + return Stream.of( + Arguments.of(ChatModels.ANTHROPIC, ANTHROPIC, null), + Arguments.of(ChatModels.ANTHROPIC, ANTHROPIC, " "), + Arguments.of(ChatModels.OPENAI_COMPATIBLE, COMPATIBLE, null), + Arguments.of(ChatModels.OPENAI_COMPATIBLE, COMPATIBLE, "")); + } + + static Stream invalidKeys() { + return Stream.of( + Arguments.of(ChatModels.ANTHROPIC, ANTHROPIC, "key\u0000"), + Arguments.of(ChatModels.OPENAI_COMPATIBLE, COMPATIBLE, "keyä")); + } + + static Stream listers() { + return Stream.of( + Arguments.of(ChatModels.ANTHROPIC, ANTHROPIC), Arguments.of(ChatModels.OPENAI_COMPATIBLE, COMPATIBLE)); + } + + @ParameterizedTest + @MethodSource("blankKeys") + void aBlankKeyIsACallerThatSkippedProvidersMissingAndNoRequestIsSent( + String kind, Function lister, String key) + throws Exception { + ChatModelCatalog.Discovery discovery = Mockito.spy(new ChatModelCatalog.Discovery()); + var llm = new PipelineConfig.Llm(kind, "http://127.0.0.1:9/v1", key, null, false, null, null); + + var refused = catchThrowableOfType( + IllegalArgumentException.class, () -> lister.apply(discovery).list(llm)); + + assertThat(refused).hasMessage(ChatModelCatalog.Discovery.KEY_REQUIRED); + verify(discovery, never()).send(any()); + } + + @ParameterizedTest + @MethodSource("invalidKeys") + void aKeyThatCannotGoIntoTheHeaderIsRefusedBeforeAnyRequest( + String kind, Function lister, String key) + throws Exception { + ChatModelCatalog.Discovery discovery = Mockito.spy(new ChatModelCatalog.Discovery()); + var llm = new PipelineConfig.Llm(kind, "http://127.0.0.1:9/v1", key, null, false, null, null); + + var refused = catchThrowableOfType( + ChatModelCatalog.ListingException.class, + () -> lister.apply(discovery).list(llm)); + + assertThat(refused.reason()).isEqualTo(ChatModelCatalog.INVALID_KEY); + verify(discovery, never()).send(any()); + } + + @ParameterizedTest + @MethodSource("listers") + void aMalformedBaseUrlIsReportedBeforeTheKeyIsLookedAt( + String kind, Function lister) throws Exception { + ChatModelCatalog.Discovery discovery = Mockito.spy(new ChatModelCatalog.Discovery()); + var llm = new PipelineConfig.Llm(kind, "ftp://127.0.0.1:9/v1", "key\u0000", null, false, null, null); + + var refused = catchThrowableOfType( + ChatModelCatalog.ListingException.class, + () -> lister.apply(discovery).list(llm)); + + assertThat(refused.reason()).isEqualTo(ChatModelCatalog.INVALID_BASE_URL); + verify(discovery, never()).send(any()); + } +} diff --git a/backend/src/test/java/de/codeministry/leadgen/chat/catalog/OllamaModelsTest.java b/backend/src/test/java/de/codeministry/leadgen/chat/catalog/OllamaModelsTest.java new file mode 100644 index 0000000..8d1cf68 --- /dev/null +++ b/backend/src/test/java/de/codeministry/leadgen/chat/catalog/OllamaModelsTest.java @@ -0,0 +1,85 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat.catalog; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import de.codeministry.leadgen.config.model.PipelineConfig; +import de.codeministry.leadgen.llm.ChatModels; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * ISC-505: one model the Ollama server cannot describe is left out, and the connection still lists + * the others — whether the describe failed with a checked exception or an unchecked one. The list + * it came from did answer, so the connection is not "could not be reached". + */ +class OllamaModelsTest { + + private final WireMockServer server = + new WireMockServer(WireMockConfiguration.options().dynamicPort()); + + @BeforeEach + void start() { + server.start(); + } + + @AfterEach + void stop() { + server.stop(); + } + + @Test + void aModelThatCannotBeDescribedIsSkippedAndTheOthersAreListed() throws Exception { + server.stubFor(get(urlPathEqualTo("/api/tags")) + .willReturn(okJson("{\"models\":[{\"name\":\"model-a\"},{\"name\":\"model-b\"}," + + "{\"name\":\"model-c\"},{\"name\":\"model-d\"}]}"))); + describe("model-a", "{\"capabilities\":[\"tools\"]}"); + // Read, and then refused by something past the parser: an unchecked failure on one model. + describe("model-b", "{\"capabilities\":[\"tools\"],\"unexpected\":true}"); + // Not JSON at all: a checked failure on one model. + server.stubFor(post(urlPathEqualTo("/api/show")) + .withRequestBody(matchingJsonPath("$.model", equalTo("model-c"))) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{not json"))); + describe("model-d", "{\"capabilities\":[\"tools\"]}"); + + ChatModelCatalog.Discovery discovery = Mockito.spy(new ChatModelCatalog.Discovery()); + doAnswer(invocation -> { + JsonNode answer = (JsonNode) invocation.callRealMethod(); + if (answer.has("unexpected")) { + throw new IllegalStateException("describing this model failed unexpectedly"); + } + return answer; + }) + .when(discovery) + .send(any()); + + var llm = new PipelineConfig.Llm(ChatModels.OLLAMA, server.baseUrl() + "/v1", null, null, false, null, null); + + assertThat(new OllamaModels(discovery).list(llm)).containsExactly("model-a", "model-d"); + server.verify(4, postRequestedFor(urlPathEqualTo("/api/show"))); + } + + private void describe(String model, String body) { + server.stubFor(post(urlPathEqualTo("/api/show")) + .withRequestBody(matchingJsonPath("$.model", equalTo(model))) + .willReturn(okJson(body))); + } +} diff --git a/backend/src/test/java/de/codeministry/leadgen/chat/catalog/UnknownHostTest.java b/backend/src/test/java/de/codeministry/leadgen/chat/catalog/UnknownHostTest.java new file mode 100644 index 0000000..175d2e3 --- /dev/null +++ b/backend/src/test/java/de/codeministry/leadgen/chat/catalog/UnknownHostTest.java @@ -0,0 +1,93 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2026 Marcello Muscara (codeministry) + * + * Licensed under the Apache License, Version 2.0. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + */ +package de.codeministry.leadgen.chat.catalog; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.withSettings; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.ThrowableProxyUtil; +import ch.qos.logback.core.read.ListAppender; +import de.codeministry.leadgen.config.ConfigRegistry; +import de.codeministry.leadgen.config.ConfigSnapshot; +import de.codeministry.leadgen.config.model.PipelineConfig; +import de.codeministry.leadgen.llm.ChatModels; +import java.net.UnknownHostException; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.slf4j.LoggerFactory; + +/** + * ISC-505: a host that does not resolve is "could not be reached", a fixed sentence; the name the + * exception carries reaches neither the client nor the server log. + * + *

No lookup leaves the machine: the catalog's {@link ChatModelCatalog.Discovery} is replaced as + * it is constructed, and its {@code send} fails the way the HTTP client does for a name nobody + * resolves. Everything else — the request, the lister, the reason, the log — is the real code. + */ +class UnknownHostTest { + + private static final String HOST = "model-host-nobody-runs.invalid"; + + @Test + void anUnknownHostIsUnreachableWithoutNamingTheHost() throws Exception { + var primary = new PipelineConfig.Llm( + ChatModels.ANTHROPIC, + "http://" + HOST, + "primary-key", + null, + false, + new PipelineConfig.Llm.Models(null, "model-z", null, null, null, null, null, "model-x"), + null); + var pipeline = Mockito.mock(PipelineConfig.class); + given(pipeline.llm()).willReturn(primary); + given(pipeline.chat()).willReturn(new PipelineConfig.Chat(null, null, null, null)); + var registry = Mockito.mock(ConfigRegistry.class); + given(registry.snapshot()).willReturn(new ConfigSnapshot(pipeline, null, null, null, null, null)); + + Logger root = (Logger) LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + ListAppender logged = new ListAppender<>(); + logged.start(); + root.addAppender(logged); + ChatModelsView view; + try (var discoveries = mockConstruction( + ChatModelCatalog.Discovery.class, + withSettings().defaultAnswer(CALLS_REAL_METHODS), + (discovery, _) -> + doThrow(new UnknownHostException(HOST)).when(discovery).send(any()))) { + view = new ChatModelCatalog(registry).models(); + assertThat(discoveries.constructed()).hasSize(1); + } finally { + root.detachAppender(logged); + } + + ConnectionView connection = view.connections().getFirst(); + assertThat(connection.reachable()).isFalse(); + assertThat(connection.reason()) + .isEqualTo(ChatModelCatalog.UNREACHABLE) + .doesNotContain("model-host") + .doesNotContain("nobody-runs") + .doesNotContain("invalid"); + assertThat(connection.models()).isEmpty(); + assertThat(logged.list).isNotEmpty(); + for (ILoggingEvent event : logged.list) { + assertThat(event.getFormattedMessage()).doesNotContain("nobody-runs"); + if (event.getThrowableProxy() != null) { + assertThat(ThrowableProxyUtil.asString(event.getThrowableProxy())) + .doesNotContain("nobody-runs"); + } + } + } +} diff --git a/backend/src/test/java/de/codeministry/leadgen/config/ConfigFixtures.java b/backend/src/test/java/de/codeministry/leadgen/config/ConfigFixtures.java index 83034a6..e006999 100644 --- a/backend/src/test/java/de/codeministry/leadgen/config/ConfigFixtures.java +++ b/backend/src/test/java/de/codeministry/leadgen/config/ConfigFixtures.java @@ -77,6 +77,7 @@ private static Map neutralPlaceholders() { values.put("LLM_TIMEOUT", ""); values.put("LLM_BATCH", ""); values.put("LLM_CONCURRENCY", ""); + values.put("LLM_BILLED", ""); values.put("FETCH_CONCURRENCY", ""); values.put("LLM_MODEL_EXTRACTION", ""); values.put("LLM_MODEL_CONTENT", ""); @@ -88,6 +89,12 @@ private static Map neutralPlaceholders() { values.put("LLM_MODEL_CHAT", ""); values.put("CHAT_MAX_CALLS_PER_DAY", ""); values.put("CHAT_MAX_TOOL_ROUNDS", ""); + values.put("CHAT_LLM_PROVIDER", ""); + values.put("CHAT_LLM_BASE_URL", ""); + values.put("CHAT_LLM_API_KEY", ""); + values.put("CHAT_LLM_BILLED", ""); + values.put("CHAT_LLM_TIMEOUT", ""); + values.put("CHAT_LLM_MAX_CALLS_PER_DAY", ""); values.put("CHAT_SUGGEST_NEW_OFFERS_MIN", ""); values.put("CHAT_SUGGEST_DEADLINE_DAYS", ""); values.put("CHAT_SUGGEST_NO_REPLY_DAYS", ""); diff --git a/backend/src/test/java/de/codeministry/leadgen/web/ChatControllerTest.java b/backend/src/test/java/de/codeministry/leadgen/web/ChatControllerTest.java index 5e78035..e52b0fe 100644 --- a/backend/src/test/java/de/codeministry/leadgen/web/ChatControllerTest.java +++ b/backend/src/test/java/de/codeministry/leadgen/web/ChatControllerTest.java @@ -26,13 +26,20 @@ import de.codeministry.leadgen.chat.ChatContextKind; import de.codeministry.leadgen.chat.ChatTurnService; import de.codeministry.leadgen.chat.ConversationRepository; +import de.codeministry.leadgen.chat.ModelPick; +import de.codeministry.leadgen.chat.catalog.ChatModelCatalog; +import de.codeministry.leadgen.chat.catalog.ChatModelsView; +import de.codeministry.leadgen.chat.catalog.ConnectionView; +import de.codeministry.leadgen.chat.catalog.ModelView; import de.codeministry.leadgen.chat.suggest.FollowUp; import de.codeministry.leadgen.chat.suggest.FollowUps; import de.codeministry.leadgen.chat.suggest.Suggestion; import de.codeministry.leadgen.chat.suggest.SuggestionScope; import de.codeministry.leadgen.chat.suggest.SuggestionService; +import de.codeministry.leadgen.llm.Connections; import java.util.List; import java.util.Optional; +import java.util.OptionalInt; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; @@ -69,6 +76,9 @@ class ChatControllerTest { @MockitoBean private ChatBudget budget; + @MockitoBean + private ChatModelCatalog catalog; + @Test void saysPresentWhenAModelAnswers() { given(capability.view()).willReturn(new ChatCapabilityView(true)); @@ -101,11 +111,11 @@ void anAskedTurnTellsEveryProxyNotToBufferTheStream() { given(conversations.exists(1L)).willReturn(true); given(capability.model()).willReturn(Optional.of("chat-model")); willAnswer(call -> { - call.getArgument(3).run(); + call.getArgument(4).run(); return null; }) .given(turns) - .start(eq(1L), anyString(), any(), any()); + .start(eq(1L), anyString(), any(ModelPick.class), any(), any()); var result = mvc.post() .uri("/api/v1/chat/conversations/1/turns") @@ -181,11 +191,11 @@ void aContextOfAnUnknownKindIsABadRequest() { void aRegeneratedTurnTellsEveryProxyNotToBufferTheStream() { given(capability.model()).willReturn(Optional.of("chat-model")); willAnswer(call -> { - call.getArgument(3).run(); + call.getArgument(4).run(); return true; }) .given(turns) - .regenerate(eq(1L), eq(2L), any(), any()); + .regenerate(eq(1L), eq(2L), any(ModelPick.class), any(), any()); var result = mvc.post() .uri("/api/v1/chat/conversations/1/turns/2/regenerate") @@ -209,6 +219,232 @@ void theStatusNamesTheModelTodaysCallsAgainstTheCeilingAndTheRoundBound() { status.extractingPath("$.callsUsed").isEqualTo(12); status.extractingPath("$.callsLimit").isEqualTo(200); status.extractingPath("$.toolRounds").isEqualTo(6); + status.extractingPath("$.secondCalls").isNull(); + status.extractingPath("$.secondLimit").isNull(); + } + + /** ISC-510: with a second connection the status names its counter and ceiling beside the chat's. */ + @Test + void theStatusNamesTheSecondConnectionsCounterBesideTheChats() { + given(capability.model()).willReturn(Optional.of("chat-model")); + given(budget.used()).willReturn(3); + given(budget.limit()).willReturn(10); + given(budget.toolRounds()).willReturn(6); + given(budget.secondLimit()).willReturn(OptionalInt.of(2)); + given(budget.used(Connections.Name.SECOND)).willReturn(2); + + var status = + assertThat(mvc.get().uri("/api/v1/chat/status")).hasStatusOk().bodyJson(); + status.extractingPath("$.callsUsed").isEqualTo(3); + status.extractingPath("$.callsLimit").isEqualTo(10); + status.extractingPath("$.secondCalls").isEqualTo(2); + status.extractingPath("$.secondLimit").isEqualTo(2); + } + + /** + * ISC-505: the model select's list, per connection, with its kind, billing, reachability and + * the default flagged — the catalog's answer as it is, on the wire. + */ + @Test + void theModelsListEachConnectionWithItsModelsAndFlags() { + given(capability.model()).willReturn(Optional.of("model-a")); + given(catalog.models()) + .willReturn(new ChatModelsView( + List.of( + new ConnectionView( + "PRIMARY", + "ollama", + false, + true, + null, + List.of(new ModelView("model-a", true), new ModelView("model-b", false))), + new ConnectionView( + "SECOND", + "anthropic", + true, + false, + "the model list answered HTTP 500", + List.of())), + "model-a")); + + var models = + assertThat(mvc.get().uri("/api/v1/chat/models")).hasStatusOk().bodyJson(); + models.extractingPath("$.connections[0].name").isEqualTo("PRIMARY"); + models.extractingPath("$.connections[0].billed").isEqualTo(false); + models.extractingPath("$.connections[0].reachable").isEqualTo(true); + models.extractingPath("$.connections[0].models[0].name").isEqualTo("model-a"); + models.extractingPath("$.connections[0].models[0].defaultModel").isEqualTo(true); + models.extractingPath("$.connections[0].models[1].defaultModel").isEqualTo(false); + models.extractingPath("$.connections[1].name").isEqualTo("SECOND"); + models.extractingPath("$.connections[1].billed").isEqualTo(true); + models.extractingPath("$.connections[1].reachable").isEqualTo(false); + models.extractingPath("$.connections[1].reason").isEqualTo("the model list answered HTTP 500"); + models.extractingPath("$.connections[1].models").asArray().isEmpty(); + models.extractingPath("$.defaultModelName").isEqualTo("model-a"); + } + + /** + * The model a turn without a pick takes travels on its own, verbatim as configured, so the select + * can name it even when the primary lists it under another spelling or not at all; and as an + * explicit null when there is none. + */ + @Test + void theModelsNameTheDefaultEvenWhenNoListedModelCarriesTheFlag() { + given(capability.model()).willReturn(Optional.of("model-a")); + given(catalog.models()) + .willReturn(new ChatModelsView( + List.of(new ConnectionView("PRIMARY", "ollama", false, false, "x", List.of())), "model-a")); + + assertThat(mvc.get().uri("/api/v1/chat/models")) + .hasStatusOk() + .bodyJson() + .extractingPath("$.defaultModelName") + .isEqualTo("model-a"); + + given(catalog.models()) + .willReturn(new ChatModelsView( + List.of(new ConnectionView("PRIMARY", "ollama", false, true, null, List.of())), null)); + + assertThat(mvc.get().uri("/api/v1/chat/models")) + .hasStatusOk() + .bodyText() + .contains("\"defaultModelName\":null"); + } + + /** ISC-505: like the status, the list is absent where no chat model is configured, and nobody is asked. */ + @Test + void theModelsAreNotFoundWithoutAChatModel() { + given(capability.model()).willReturn(Optional.empty()); + + assertThat(mvc.get().uri("/api/v1/chat/models")).hasStatus(404); + verify(catalog, never()).models(); + } + + /** The catalog the two connections offer in the ISC-506 cases: model-a on the primary, model-b on the second. */ + private void catalogOffersModelAOnThePrimaryAndModelBOnTheSecond() { + given(capability.model()).willReturn(Optional.of("model-a")); + given(conversations.exists(1L)).willReturn(true); + given(catalog.models()) + .willReturn(new ChatModelsView( + List.of( + new ConnectionView( + "PRIMARY", + "ollama", + false, + true, + null, + List.of(new ModelView("model-a", true))), + new ConnectionView( + "SECOND", + "openai-compatible", + true, + true, + null, + List.of(new ModelView("model-b", false)))), + "model-a")); + given(catalog.contains("PRIMARY", "model-a")).willReturn(true); + given(catalog.contains("SECOND", "model-b")).willReturn(true); + } + + /** Picks the server did not discover itself, and a pick that names one half only. */ + private static final List REFUSED_PICKS = List.of( + "\"connection\":\"SECOND\",\"model\":\"model-unlisted\"", + "\"connection\":\"PRIMARY\",\"model\":\"model-b\"", + "\"connection\":\"THIRD\",\"model\":\"model-a\"", + "\"model\":\"model-b\"", + "\"connection\":\"SECOND\""); + + /** + * ISC-506: a turn asked with a model and connection the catalog never listed together — or with + * half a pick — is a 400 before anything starts: no turn, so no row, no counter and no model call. + */ + @Test + void aTurnOnAPickTheServerDidNotDiscoverIsABadRequestAndStartsNothing() { + catalogOffersModelAOnThePrimaryAndModelBOnTheSecond(); + + for (String pick : REFUSED_PICKS) { + assertThat(mvc.post() + .uri("/api/v1/chat/conversations/1/turns") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"question\":\"Anything?\"," + pick + "}")) + .as(pick) + .hasStatus(400); + } + verify(turns, never()).start(anyLong(), anyString(), any(), any(), any()); + verify(turns, never()).start(anyLong(), anyString(), any(), any()); + } + + /** ISC-506: the same refusals on a regenerate, which carries its pick in an optional body. */ + @Test + void aRegenerateOnAPickTheServerDidNotDiscoverIsABadRequestAndStartsNothing() { + catalogOffersModelAOnThePrimaryAndModelBOnTheSecond(); + + for (String pick : REFUSED_PICKS) { + assertThat(mvc.post() + .uri("/api/v1/chat/conversations/1/turns/2/regenerate") + .contentType(MediaType.APPLICATION_JSON) + .content("{" + pick + "}")) + .as(pick) + .hasStatus(400); + } + verify(turns, never()).regenerate(anyLong(), anyLong(), any(), any(), any()); + verify(turns, never()).regenerate(anyLong(), anyLong(), any(), any()); + } + + /** + * ISC-506/507: a discovered pick reaches the turn as sent, and the default is never checked + * against the catalog — a turn without a pick asks nobody's list. + */ + @Test + void aDiscoveredPickAndTheDefaultBothStartTheirTurn() { + catalogOffersModelAOnThePrimaryAndModelBOnTheSecond(); + willAnswer(call -> { + call.getArgument(4).run(); + return null; + }) + .given(turns) + .start(eq(1L), anyString(), any(ModelPick.class), any(), any()); + + assertThat(mvc.post() + .uri("/api/v1/chat/conversations/1/turns") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"question\":\"By default?\"}")) + .hasStatusOk(); + verify(turns).start(eq(1L), eq("By default?"), eq(ModelPick.DEFAULT), any(), any()); + verify(catalog, never()).contains(any(), any()); + verify(catalog, never()).models(); + + assertThat(mvc.post() + .uri("/api/v1/chat/conversations/1/turns") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"question\":\"Anything?\",\"connection\":\"SECOND\",\"model\":\"model-b\"}")) + .hasStatusOk(); + verify(turns).start(eq(1L), eq("Anything?"), eq(new ModelPick("SECOND", "model-b")), any(), any()); + } + + /** + * ISC-506: right after a restart nothing has been listed under the current configuration, and + * {@code contains} answers false for that reason alone. The controller then reads the list once + * and checks against it, so a pick the connection does offer is not refused. + */ + @Test + void aPickIsCheckedAgainstAListReadOnceWhenNoneWasReadYet() { + catalogOffersModelAOnThePrimaryAndModelBOnTheSecond(); + given(catalog.contains("SECOND", "model-b")).willReturn(false); + willAnswer(call -> { + call.getArgument(4).run(); + return true; + }) + .given(turns) + .regenerate(eq(1L), eq(2L), any(ModelPick.class), any(), any()); + + assertThat(mvc.post() + .uri("/api/v1/chat/conversations/1/turns/2/regenerate") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"connection\":\"SECOND\",\"model\":\"model-b\"}")) + .hasStatusOk(); + verify(catalog).models(); + verify(turns).regenerate(eq(1L), eq(2L), eq(new ModelPick("SECOND", "model-b")), any(), any()); } /** ISC-476: absent where no chat model is configured, as the rest of the chat is. */ diff --git a/backend/src/test/java/de/codeministry/leadgen/web/StatusControllerTest.java b/backend/src/test/java/de/codeministry/leadgen/web/StatusControllerTest.java index 955904e..6f7caf3 100644 --- a/backend/src/test/java/de/codeministry/leadgen/web/StatusControllerTest.java +++ b/backend/src/test/java/de/codeministry/leadgen/web/StatusControllerTest.java @@ -19,7 +19,7 @@ // into the `spring-boot-webmvc-test` module. The old import compiles against // Boot 3 and simply does not exist here. @WebMvcTest(StatusController.class) -@TestPropertySource(properties = {"spring.application.name=lead-generation", "leadgen.version=0.5.4"}) +@TestPropertySource(properties = {"spring.application.name=lead-generation", "leadgen.version=0.5.5"}) class StatusControllerTest { @Autowired @@ -30,6 +30,6 @@ void returnsApplicationNameAndVersion() { Assertions.assertThat(mvc.get().uri("/api/v1/status")) .hasStatusOk() .bodyJson() - .isEqualTo("{\"application\":\"lead-generation\",\"version\":\"0.5.4\"}"); + .isEqualTo("{\"application\":\"lead-generation\",\"version\":\"0.5.5\"}"); } } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7babd21..0d646c3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -120,6 +120,16 @@ to share a name, which meant a stack trace naming it could mean either file. and 6 rounds. The chat is on wherever a scoring model exists, so an override that predates the block must not leave it unmetered or its tool loop unbounded. The reasoning is in [decisions/chat.md](decisions/chat.md). +- **The chat may ask a model on a second connection, and it has a ceiling of its own.** `chat.connection` + names a provider kind, a base URL, a key, whether it is `billed`, a `timeout` and `max_calls_per_day`, every + value a placeholder in `.env`; with no provider it is absent and the chat runs on `llm` alone. The pipeline + never reads it. The drawer's select lists the models each connection reports and that can call tools — read from + the connection, not from a list here — and a turn may name only a model the server listed itself. A call on the + second connection counts against `chat.max_calls_per_day` and its own ceiling at once; absent, that ceiling is + `50`, never no ceiling. `billed` on either connection decides the marker in the select and under the answer; + left empty it is true for `anthropic` and false for every other kind. A new conversation always starts on + `llm.models.chat` on the primary connection. The reasoning is in + [decisions/chat.md](decisions/chat.md) § A second connection, discovered, not allowlisted. - **`chat.suggestions.*` decides which questions the empty chat offers, and none of it spends a call.** Six thresholds, each a rule over the numbers a screen already shows: `new_offers_min`, `deadline_days`, `no_reply_days`, and the tag rise as `tag_window_days`, `tag_rise_percent` and `tag_rise_min_offers`. @@ -216,6 +226,7 @@ rationale. `*` marks a credential. | `LLM_BATCH` | `false` | Half the price, answers minutes later. Only the Messages API batch is implemented; `true` on any other provider is fatal at load rather than quietly synchronous at full price. | | `LLM_TIMEOUT` | `PT120S` | ISO-8601. How long one request to a model may take. | | `LLM_CONCURRENCY` | `1` | `llm.concurrency`: how many adverts CONTENT, FIELDS and the synchronous SCORE work at once, and how many embedding batches DEDUPE and RETRIEVAL have in flight. Refused above the database connection pool. The endpoint has to serve that many at once; the budget is unchanged, only the clock moves. | +| `LLM_BILLED` | — | `llm.billed`: whether a call on the primary connection is paid for, which the chat's select and answers mark. `true` or `false`; empty means billed for `anthropic` and not billed for every other kind. Anything else is refused at load. | | `LLM_MODEL_SCORING` | — | The judge, and the default of the list below. Also the fallback of every model key left empty: `LLM_MODEL_CONTENT`, `LLM_MODEL_FIELDS`, `LLM_MODEL_EXTRACTION` and `LLM_MODEL_CHAT`. Changing it re-judges; it does not make CONTENT or FIELDS due again, even for the stages that fall back to it. | | `LLM_MODEL_CONTENT` | — | `llm.models.content`: the content classifier, which labels the blocks of an advert no rule and no cached label decided. Empty means `LLM_MODEL_SCORING`. Set and then changed, it makes CONTENT due again for every advert segmented under another model; cached block labels stand, so only blocks nobody has a label for go to the new model. | | `LLM_MODEL_FIELDS` | — | `llm.models.fields`: the field extractor, which reads start, duration and apply-by out of the advert. Empty means `LLM_MODEL_SCORING`. Set and then changed, it makes FIELDS due again for every advert read under another model. | @@ -226,6 +237,12 @@ rationale. `*` marks a credential. | `LLM_MODEL_CHAT` | — | `llm.models.chat`: the chat in the drawer every screen opens. Empty means `LLM_MODEL_SCORING`, and the startup log says which key decided. The model has to call tools, which not every judge does well. With neither key set the chat is absent: the header draws no button and no chat request leaves the browser. | | `CHAT_MAX_CALLS_PER_DAY` | `200` | `chat.max_calls_per_day`: the chat's own daily ceiling on model requests, counted in `chat_call_budget` beside `llm.budget` and never through it, so questions cannot starve the nightly run nor a spent night silence the chat. `0` means no calls. Left out of a `pipeline.yaml` override — the key or the whole `chat:` block — it is `200`, not no ceiling (changed from "absent = no ceiling": the chat is on by default wherever a scoring model exists). A spent day ends a turn with its reason. | | `CHAT_MAX_TOOL_ROUNDS` | `6` | `chat.max_tool_rounds`: how many rounds of tool calls one chat turn may take before it has to answer. Past it the turn ends with its reason and keeps what it had said, rather than showing an answer the model never finished. Left out of an override it is `6`; there is no unbounded setting, because a model that keeps asking for tools would loop for ever. | +| `CHAT_LLM_PROVIDER` | — | `chat.connection.provider`: the chat's optional second connection, `anthropic`, `ollama` or `openai-compatible`. Empty means none, and the chat runs on `llm` alone. An unknown kind is refused at load. Only the chat reads it. | +| `CHAT_LLM_BASE_URL` | — | `chat.connection.base_url`. Required for `anthropic` and `openai-compatible`; refused at load without it. | +| `CHAT_LLM_API_KEY` * | — | `chat.connection.api_key`. `ollama` needs none. Masked in the startup banner and in every tool result a model reads. | +| `CHAT_LLM_BILLED` | — | `chat.connection.billed`: as `LLM_BILLED`, for the second connection. | +| `CHAT_LLM_TIMEOUT` | `PT120S` | `chat.connection.timeout`: how long one request on the second connection may take. | +| `CHAT_LLM_MAX_CALLS_PER_DAY` | `50` | `chat.connection.max_calls_per_day`: the second connection's own daily ceiling, counted in `chat_connection_call_budget`. A call on it counts here and against `CHAT_MAX_CALLS_PER_DAY` at once, and a call either refuses counts on neither. Left out of an override it is `50`, never no ceiling; `0` means no calls. A spent day ends a turn on it with its reason, and the primary's models still answer. | | `CHAT_SUGGEST_NEW_OFFERS_MIN` | `1` | `chat.suggestions.new_offers_min`: the empty chat suggests the last run's new offers once that run wrote at least this many. | | `CHAT_SUGGEST_DEADLINE_DAYS` | `7` | `chat.suggestions.deadline_days`: suggests the open offers whose apply-by date falls within this many days from today. | | `CHAT_SUGGEST_NO_REPLY_DAYS` | `14` | `chat.suggestions.no_reply_days`: suggests the sent applications that have had no reply for at least this many days. | diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index 2fb1cb2..2439d97 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -9,7 +9,7 @@ is *where does this value live* or *who is allowed to change it*. The reasoning column belongs to the decision records; each section links the one that carries it. > [!NOTE] -> Derived from `V1` to `V35`. When this document and a migration disagree, the migration wins, +> Derived from `V1` to `V37`. When this document and a migration disagree, the migration wins, > and the fix is here. There is no ORM: every write is a plain SQL statement in the service > that owns it, so "who writes this" has one answer per column and it is worth writing down. @@ -383,10 +383,10 @@ overwrite themselves. Primary key `(run_id, position)`, cascades from `pipeline_ The three cache and counter tables, `fetched_page`, `content_block_label` and `llm_call_budget`, and the `instance` row the package folders are owned by, are in § 2. -## 5. The chat's five tables +## 5. The chat's six tables -Five tables that the chat writes alone and no pipeline stage reads (`ChatIsolationTest` fails on -a pipeline source naming one of them). Dropping all five loses the stored conversations and +Six tables that the chat writes alone and no pipeline stage reads (`ChatIsolationTest` fails on +a pipeline source naming one of them). Dropping all six loses the stored conversations and nothing else; the rollback is in [decisions/chat.md](decisions/chat.md), with the reasoning behind every column below. @@ -424,6 +424,7 @@ erDiagram bigint replaces_turn_id FK jsonb citations text end_reason + text connection } chat_tool_call { bigint id PK @@ -437,9 +438,14 @@ erDiagram date day PK int calls } + chat_connection_call_budget { + date day PK + text connection PK + int calls + } ``` -Every write is a statement in `chat/ConversationRepository`, except the budget's, which is +Every write is a statement in `chat/ConversationRepository`, except the two budgets', which are `chat/ChatBudget`'s. **`chat_conversation`** (`V32`). One row per conversation. `create` inserts it with an empty @@ -456,9 +462,11 @@ it after `create`, `startTurn` and `rename`, the migration filled it once with ` is the first offer of `chat_context`, mirrored by `create` and `replaceContext`, and still read by `pinnedOffer` for the turn's pinned lookup; a later release drops it. -**`chat_turn`** (`V32`, `V34`, `V36`). One row per question asked, including every regenerated one. +**`chat_turn`** (`V32`, `V34`, `V36`, `V37`). One row per question asked, including every regenerated one. `startTurn` writes `conversation_id`, the next `ordinal` in the conversation (unique with it), -`question`, `model` — the chat model the turn was sent to — and, on a regenerate, +`question`, `model` — the chat model the turn was sent to — `connection` (`V37`), the connection it +was sent on by name, `PRIMARY` or `SECOND` and never a base URL, with a `CHECK` holding it to those two +and `NULL` for every turn stored before it (spec 025) — and, on a regenerate, `replaces_turn_id`, the turn this one answers again; both stay. `answer_md` starts empty and `append` concatenates each piece of text as it leaves for the browser, citations already resolved to `[n](cite:offer/ID)` or `⟨unverified:ID⟩`, so a stopped or failed turn keeps exactly what the @@ -502,6 +510,14 @@ same guarded upsert, against `chat.max_calls_per_day`. A table of its own becaus `llm_call_budget` is keyed by the day alone and a shared counter would let questions starve the nightly run. No key points at it, and emptying it hands the day's allowance out again. +**`chat_connection_call_budget`** (`V37`). Beside `chat_call_budget`, never instead of it: one row per +calendar day and connection, primary key `(day, connection)`, `connection` held to `PRIMARY` or +`SECOND` by a `CHECK`, with a `calls` counter. `ChatBudget.take(SECOND)` takes it with the same +guarded upsert against `chat.connection.max_calls_per_day`, after `chat_call_budget`, so a call on the +second connection counts against both; a primary call writes no row here. Read for `secondCalls` in +`GET /chat/status`. No key points at it, and emptying it hands the second connection's day out again +(spec 025, ISC-510). + ## 6. Status-like columns and their values Every state in the schema is a `TEXT` column, and with one exception nothing in the database @@ -523,6 +539,7 @@ the transitions. | `content_block_label.kind` and the `kind` inside `offer.content_blocks` | `CONTENT`, `CHROME`, `FORM`, `TAXONOMY`, `AGENCY`, `LEGAL` | `content/ContentKind` | | | `content_block_label.decided_by` and the `by` inside `offer.content_blocks` | `RULE`, `CACHE`, `MODEL` | `content/Decider` | | | `source.kind` | `file`, `imap` | each connector's `type()` | | +| `chat_turn.connection`, `chat_connection_call_budget.connection` | `PRIMARY`, `SECOND`; `chat_turn.connection` null before `V37` | `llm/Connections.Name`, an enum, and a `CHECK` in `V37` on each column | not a state machine: written once per turn or counter row | | `chat_context.kind` | `OFFER`, `SHORTLIST_VIEW`, `ANALYTICS_WINDOW` | `chat/ChatContextKind`, an enum, and a `CHECK` in `V35` with one per kind for its columns | not a state machine: a chip is replaced, never moved | Two values a reader will look for and not find as columns: `ScoreState` (`ANY`, `SCORED`, @@ -547,7 +564,7 @@ services. It misses a statement assembled from strings and a CTE's alias, so the checklist to walk, not an answer: ```bash -rg -n -o -i -e "(FROM|INTO|UPDATE|JOIN|DELETE FROM)\s+(offer_score_reason|offer|source_run|source|score_batch|pipeline_run_stage|pipeline_run|pipeline_stage|application_event|application|fetched_page|content_block_label|llm_call_budget|chat_conversation|chat_turn|chat_tool_call|chat_context|chat_call_budget)\b" \ +rg -n -o -i -e "(FROM|INTO|UPDATE|JOIN|DELETE FROM)\s+(offer_score_reason|offer|source_run|source|score_batch|pipeline_run_stage|pipeline_run|pipeline_stage|application_event|application|fetched_page|content_block_label|llm_call_budget|chat_conversation|chat_turn|chat_tool_call|chat_context|chat_connection_call_budget|chat_call_budget)\b" \ backend/src/main/java -g '*.java' | sort | uniq -c ``` diff --git a/docs/decisions/chat.md b/docs/decisions/chat.md index 44121ac..0b812c2 100644 --- a/docs/decisions/chat.md +++ b/docs/decisions/chat.md @@ -213,6 +213,41 @@ model has to call tools, which not every model a judge runs on does well; which it reliably enough is a measurement against a real instance, open in spec 019 as fog, the way the routing bake-off was measured for the stages. +## A second connection, discovered, not allowlisted + +**The chat may ask a model on a second connection beside `llm`, and only the chat reads it** (spec 025). +`llm.provider`, `base_url` and `api_key` are one block every stage shares, so a hard question for a stronger +hosted model used to mean reconfiguring the whole instance, and the nightly judge with it. `chat.connection` is a +second block of the same shape, under `chat:` rather than `llm:` so that `llm` stays the pipeline's single +provider; per-stage connections were offered and not taken. `llm/Connections` names the two `PRIMARY` and +`SECOND` and maps each onto the shape `ChatModels.of` already takes, so a model on the second connection is built +by the same call, and a stored turn names its connection, never a URL (`chat_turn.connection`, `V37`). + +**The model list is read from each connection, not written in `.env` — the one place the chat departs from the +run button.** `scoring_options` is an allowlist because a name arriving in a request decides what gets bought +([pipeline-scoring.md](pipeline-scoring.md)). The operator chose discovery here: Ollama's own `/api/tags`, kept +where `/api/show` lists `tools`; the `/v1/models` of the hosted and the `openai-compatible` kinds, every model +counted as tool-capable because neither publishes the capability. The check survives in another form. The server +accepts a pick only when its own list holds it (`ChatModelCatalog.contains`), so a request still cannot name an +arbitrary model; a list not read yet under the current configuration is read once rather than refusing every pick +after a restart; a partial pick is a 400. The list is cached for 60 s per configuration snapshot, a connection that +does not answer is listed with its reason and no models, and the other still answers. The price: a model pulled or +published tomorrow is selectable without a configuration change. + +**A ceiling of its own, taken with the chat's in one transaction.** A call on the second connection counts against +`chat.max_calls_per_day` and `chat.connection.max_calls_per_day` (`chat_connection_call_budget`); when either +refuses, the other is rolled back, so a refused call counts on neither. Absent is 50, never no ceiling, for the +reason the chat's own ceilings have one. `billed` is a key on each connection, because an `openai-compatible` +endpoint may be a local server or a paid one; empty means billed for `anthropic` only. + +**Picked per turn, never inherited, never a fallback.** The select sits beside the composer; the next model reads +the conversation as the turns before it left it — question and answer per turn, never an earlier tool result — under +the same masker, ledger and round bound. A new conversation always starts on `llm.models.chat` on the primary +connection and the pick lives in memory, never in `localStorage`, so a billed model is reached only by a pick in +that conversation; a reopened one starts on its last turn's model. A model that fails, times out or is refused ends +its turn with its reason and is never retried elsewhere. The suggestions and the semantic tool's query vector stay +on the primary connection whichever model a turn uses. + ## Streaming: server-sent events over a `fetch` POST **`SseEmitter` on the server, `fetch` plus `eventsource-parser` in the browser.** A turn is a @@ -358,6 +393,15 @@ full-screen modal sheet there, and following a source collapses it into a bar ab navigation that names the conversation, marks a streaming turn, and expands back on a tap — with `?chat` still in the URL, so "a change of screen keeps it open" holds literally on every width. +**The panel loads on the drawer's first open, not with the page.** Hosted in the root, the whole +drawer — its components, DOMPurify and the answer renderer — sat in the initial bundle on every +screen, and spec 025's model select pushed that bundle past its 1 MB budget (1.00 MB, 3.55 kB over). +The root wraps `lg-chat-panel` in `@defer (when …)` on the store's open state and keeps it once +loaded: the initial total fell to 867 kB, and the panel is its own 140 kB chunk. The budget stays; +the header button, its living mark and the store remain eager, and the panel seeds its "followed a +source" comparison from the URL it was built under, because the navigation that opened it has +already ended by then. + ## The living mark: four frames of one turn The assistant's glyph beside each answer is the brand's living mark, and its frame is a pure diff --git a/frontend/package.json b/frontend/package.json index 1752cc3..7c134cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.5.4", + "version": "0.5.5", "scripts": { "start": "ng serve", "build": "ng build", diff --git a/frontend/public/i18n/de.json b/frontend/public/i18n/de.json index b5a0958..0cb90b9 100644 --- a/frontend/public/i18n/de.json +++ b/frontend/public/i18n/de.json @@ -957,6 +957,24 @@ "done": "Antwort fertig, {count, plural, =0 {keine Quellen} one {1 Quelle} other {# Quellen}}" }, "stop": "Stoppen", + "model": { + "label": "Modell", + "connection": { + "PRIMARY": "Primäre Verbindung", + "SECOND": "Zweite Verbindung" + }, + "billed": "kostenpflichtig", + "billedOption": "{name} · kostenpflichtig", + "none": "Keine Modelle", + "default": "Standard · {name}", + "defaultBare": "Standard", + "groupUnavailable": "{connection} · {reason}", + "note": "{connection}: {reason}", + "spent": "Heute keine Aufrufe mehr übrig", + "unreachable": "Nicht erreichbar: {reason}", + "unreachableBare": "Nicht erreichbar", + "unavailable": "{reason}" + }, "minibar": { "expand": "Zurück zum Chat" }, @@ -964,7 +982,8 @@ "copy": "Antwort mit Quellen kopieren", "copied": "Kopiert", "regenerate": "Diese Antwort neu schreiben", - "unverified": "ungeprüft" + "unverified": "ungeprüft", + "model": "Geschrieben von" }, "steps": { "summary": "{tools, plural, one {1 Werkzeug} other {# Werkzeuge}} genutzt · {rows, plural, one {1 Zeile} other {# Zeilen}}", @@ -1054,9 +1073,12 @@ "chatTitle": "Der Chat", "calls": "Modellaufrufe heute", "callsValue": "{{used}} von {{limit}}", + "secondCalls": "Aufrufe über die zweite Verbindung heute", + "secondCallsValue": "{{used}} von {{limit}}", "rounds": "Werkzeugrunden pro Antwort", "chatLoading": "Wird geladen…", - "chatFailed": "Der Status wurde nicht geladen." + "chatFailed": "Der Status wurde nicht geladen.", + "refreshFailed": "Die letzte Aktualisierung ist fehlgeschlagen; die Zahlen sind womöglich veraltet." }, "select": { "action": "Auswählen", diff --git a/frontend/public/i18n/en.json b/frontend/public/i18n/en.json index 939d557..9fe1771 100644 --- a/frontend/public/i18n/en.json +++ b/frontend/public/i18n/en.json @@ -957,6 +957,24 @@ "done": "Answer ready, {count, plural, =0 {no sources} one {1 source} other {# sources}}" }, "stop": "Stop", + "model": { + "label": "Model", + "connection": { + "PRIMARY": "Primary connection", + "SECOND": "Second connection" + }, + "billed": "billed", + "billedOption": "{name} · billed", + "none": "No models", + "default": "Default · {name}", + "defaultBare": "Default", + "groupUnavailable": "{connection} · {reason}", + "note": "{connection}: {reason}", + "spent": "No calls left today", + "unreachable": "Not reachable: {reason}", + "unreachableBare": "Not reachable", + "unavailable": "{reason}" + }, "minibar": { "expand": "Back to the chat" }, @@ -964,7 +982,8 @@ "copy": "Copy the answer with its sources", "copied": "Copied", "regenerate": "Write this answer again", - "unverified": "unverified" + "unverified": "unverified", + "model": "Written by" }, "steps": { "summary": "Used {tools, plural, one {1 tool} other {# tools}} · {rows, plural, one {1 row} other {# rows}}", @@ -1054,9 +1073,12 @@ "chatTitle": "The chat", "calls": "Model calls today", "callsValue": "{{used}} of {{limit}}", + "secondCalls": "Calls on the second connection today", + "secondCallsValue": "{{used}} of {{limit}}", "rounds": "Tool rounds per answer", "chatLoading": "Loading…", - "chatFailed": "The status did not load." + "chatFailed": "The status did not load.", + "refreshFailed": "The last refresh failed; these numbers may be out of date." }, "select": { "action": "Select", diff --git a/frontend/src/app/app.html b/frontend/src/app/app.html index 31dece2..0440590 100644 --- a/frontend/src/app/app.html +++ b/frontend/src/app/app.html @@ -1,3 +1,12 @@ - + + + @defer (when chatOpen()) { + + } + diff --git a/frontend/src/app/app.spec.ts b/frontend/src/app/app.spec.ts index b608ceb..6cc7349 100644 --- a/frontend/src/app/app.spec.ts +++ b/frontend/src/app/app.spec.ts @@ -21,7 +21,7 @@ describe('App', () => { const fixture = TestBed.createComponent(App); fixture.detectChanges(); - httpMock.expectOne('/api/v1/status').flush({application: 'lead-generation', version: '0.5.4'}); + httpMock.expectOne('/api/v1/status').flush({application: 'lead-generation', version: '0.5.5'}); // Run ingest and the model choice left the header for the workflow screen (operator, // 2026-09-27), so the shell does not create the scoring-model store: the model list is // not asked for here. The ingest store is created at the root (fix 2F-5), so its @@ -34,7 +34,7 @@ describe('App', () => { await fixture.whenStable(); fixture.detectChanges(); - expect(fixture.nativeElement.textContent).toContain('lead-generation 0.5.4'); + expect(fixture.nativeElement.textContent).toContain('lead-generation 0.5.5'); httpMock.verify(); }); diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index efad7b4..cb48a6e 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,5 +1,8 @@ -import {ChangeDetectionStrategy, Component, inject} from '@angular/core'; +import {ChangeDetectionStrategy, Component, computed, effect, inject, untracked} from '@angular/core'; +import {injectDispatch} from '@ngrx/signals/events'; import {RefreshStore} from '@core/refresh/refresh.store'; +import {chatEvents} from '@core/store/chat.events'; +import {ChatStore} from '@core/store/chat.store'; import {IngestStore} from '@core/store/ingest.store'; import {ChatPanel} from '@features/chat/chat-panel/chat-panel'; import {AppShell} from '@layout/app-shell/app-shell'; @@ -16,6 +19,11 @@ import {AppShell} from '@layout/app-shell/app-shell'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class App { + private readonly chat = inject(ChatStore); + private readonly dispatch = injectDispatch(chatEvents); + /** The drawer is open, by the URL's `?chat`: the deferred panel is created then, and stays. */ + protected readonly chatOpen = computed(() => this.chat.present() === true && this.chat.view() !== 'closed'); + /** * `RefreshStore` is injected for its existence and for nothing it returns. * @@ -37,5 +45,15 @@ export class App { // it on every screen; since that control moved to the workflow screen, nothing did anywhere // else, and a run ending on /pipeline refreshed nothing and said nothing. inject(IngestStore); + // The model catalog each time the drawer opens, so a connection that went away or came back is + // seen (spec 025, ISC-511). Here and not in the composer: the drawer stops checking its projected + // content while closed, so an effect in there never saw it close and asked only on the first open. + // Not on a page load, and nothing without a chat model (ISC-513). + let wasOpen = false; + effect(() => { + const open = this.chatOpen(); + if (open && !wasOpen) untracked(() => this.dispatch.modelsRequested()); + wasOpen = open; + }); } } diff --git a/frontend/src/app/core/api/chat-stub.ts b/frontend/src/app/core/api/chat-stub.ts index 5b9b8d1..3e50b3e 100644 --- a/frontend/src/app/core/api/chat-stub.ts +++ b/frontend/src/app/core/api/chat-stub.ts @@ -1,6 +1,6 @@ import {Observable} from 'rxjs'; -import type {ChatEvent, ChatFollowUp, ChatStatus, ChatSuggestion} from '../model/chat'; +import type {ChatEvent, ChatFollowUp, ChatModels, ChatStatus, ChatSuggestion} from '../model/chat'; /** * What `GET /suggestions` answers for an empty chat once the server does (ISC-455): at most four, @@ -15,6 +15,27 @@ export const RECORDED_SUGGESTIONS: readonly ChatSuggestion[] = [ /** What `GET /status` answers on an instance with a chat model (ISC-476). */ export const RECORDED_STATUS: ChatStatus = {model: 'chat-model', callsUsed: 12, callsLimit: 200, toolRounds: 6}; +/** + * What `GET /models` answers on an instance with one unbilled connection (ISC-505): two models that + * can call tools, the first of them the default. Neutral names, no provider and no vendor. + */ +export const RECORDED_MODELS: ChatModels = { + connections: [ + { + name: 'PRIMARY', + kind: 'local', + billed: false, + reachable: true, + reason: null, + models: [ + {name: 'model-a', defaultModel: true}, + {name: 'model-b', defaultModel: false}, + ], + }, + ], + defaultModelName: 'model-a', +}; + /** What `GET …/followups` answers under a finished turn (ISC-457): two or three questions. */ export const RECORDED_FOLLOWUPS: readonly ChatFollowUp[] = [{text: 'Only the remote ones?'}, {text: 'Which of them pay the most?'}]; diff --git a/frontend/src/app/core/api/chat.api.spec.ts b/frontend/src/app/core/api/chat.api.spec.ts index dc27ffc..8637602 100644 --- a/frontend/src/app/core/api/chat.api.spec.ts +++ b/frontend/src/app/core/api/chat.api.spec.ts @@ -5,6 +5,7 @@ import {catchError, firstValueFrom, Observable, tap, throwError, toArray} from ' import {AuthService} from '@core/auth/auth.service'; import {ChatEvent, ConversationView, formatChatCtx, parseChatCtx} from '@core/model/chat'; import {ChatApi} from './chat.api'; +import {RECORDED_MODELS} from './chat-stub'; const BASE = '/api/v1/chat'; @@ -89,6 +90,17 @@ describe('ChatApi', () => { http.expectOne({method: 'POST', url: `${BASE}/conversations/4/turns/9/stop`}).flush(null); }); + it('reads the model catalog: each connection with its billing, its reason and its models (ISC-505)', async () => { + const catalog = firstValueFrom(api.models()); + http.expectOne({method: 'GET', url: `${BASE}/models`}).flush(RECORDED_MODELS); + + const answer = await catalog; + expect(answer).toEqual(RECORDED_MODELS); + const [primary] = answer.connections; + expect(primary).toMatchObject({name: 'PRIMARY', billed: false, reachable: true, reason: null}); + expect(primary.models.filter((model) => model.defaultModel).map((model) => model.name)).toEqual(['model-a']); + }); + it('speaks the contracts of spec 020: rename, search, context, suggestions, follow-ups (ISC-449)', () => { const renamed: ConversationView[] = []; api.rename(4, 'Kafka offers').subscribe((c) => renamed.push(c)); @@ -207,6 +219,25 @@ describe('ChatApi', () => { expect(new Headers(init.headers).get('Authorization')).toBe('Bearer abc'); }); + it('sends the pick with a question and with a regenerate, both halves or neither (ISC-508)', async () => { + const fetch = vi.fn().mockImplementation(() => Promise.resolve(streamed(['event: done\ndata: {"state":"DONE"}\n\n']))); + vi.stubGlobal('fetch', fetch); + const pick = {connection: 'SECOND', model: 'model-c'} as const; + + await firstValueFrom(api.ask(4, 'q', pick).pipe(toArray())); + await firstValueFrom(api.ask(4, 'q', null).pipe(toArray())); + await firstValueFrom(api.regenerate(4, 9, pick).pipe(toArray())); + await firstValueFrom(api.regenerate(4, 9).pipe(toArray())); + + const bodies = fetch.mock.calls.map(([, init]) => JSON.parse((init as RequestInit).body as string) as unknown); + expect(bodies).toEqual([ + {question: 'q', connection: 'SECOND', model: 'model-c'}, + {question: 'q'}, + {connection: 'SECOND', model: 'model-c'}, + {}, + ]); + }); + it('ignores an event name outside the contract', async () => { vi.stubGlobal( 'fetch', diff --git a/frontend/src/app/core/api/chat.api.ts b/frontend/src/app/core/api/chat.api.ts index 2c797c5..ffdaf77 100644 --- a/frontend/src/app/core/api/chat.api.ts +++ b/frontend/src/app/core/api/chat.api.ts @@ -10,6 +10,8 @@ import { ChatEvent, ChatEventMap, ChatFollowUp, + ChatModelPick, + ChatModels, ChatStatus, ChatSuggestion, ConversationSummary, @@ -82,6 +84,14 @@ export class ChatApi { return this.http.get(`${BASE}/status`); } + /** + * The models each configured connection offers the chat, read from the connection itself (ISC-505). + * A connection that did not answer comes back with its reason and no models, not as a failed request. + */ + models(): Observable { + return this.http.get(`${BASE}/models`); + } + /** * Up to four questions the data suggests (ISC-455): for a stored conversation, or for pins not * stored yet, passed as the `?chatCtx` string the URL carries. @@ -111,14 +121,20 @@ export class ChatApi { return this.http.post(`${BASE}/conversations/${conversationId}/turns/${turnId}/stop`, null); } - /** Asks a question; the observable is the turn's event stream, and unsubscribing aborts it. */ - ask(conversationId: number, question: string): Observable { - return this.stream(`${BASE}/conversations/${conversationId}/turns`, {question}); + /** + * Asks a question; the observable is the turn's event stream, and unsubscribing aborts it. A pick + * names the model it is asked on (ISC-508); without one the default on the primary connection answers. + */ + ask(conversationId: number, question: string, pick?: ChatModelPick | null): Observable { + return this.stream(`${BASE}/conversations/${conversationId}/turns`, {question, ...pickBody(pick)}); } - /** The same question again, as a new turn that replaces the given one — a stream like `ask`. */ - regenerate(conversationId: number, turnId: number): Observable { - return this.stream(`${BASE}/conversations/${conversationId}/turns/${turnId}/regenerate`, {}); + /** + * The same question again, as a new turn that replaces the given one — a stream like `ask`, and + * like it on the picked model, which may be another than the replaced turn's (ISC-508). + */ + regenerate(conversationId: number, turnId: number, pick?: ChatModelPick | null): Observable { + return this.stream(`${BASE}/conversations/${conversationId}/turns/${turnId}/regenerate`, pickBody(pick)); } /** @@ -202,6 +218,11 @@ async function refusal(response: Response): Promise { return text; } +/** The pick as a request's fields: both halves or neither, never one alone. */ +function pickBody(pick: ChatModelPick | null | undefined): {connection?: string; model?: string} { + return pick == null ? {} : {connection: pick.connection, model: pick.model}; +} + /** `?context=` for pins not stored yet, in the URL's own `chatCtx` form; no pins, no parameter. */ function contextParam(context: readonly ChatContextItem[]): Record { const value = formatChatCtx(context); diff --git a/frontend/src/app/core/model/chat.ts b/frontend/src/app/core/model/chat.ts index 8a25266..bbb5d10 100644 --- a/frontend/src/app/core/model/chat.ts +++ b/frontend/src/app/core/model/chat.ts @@ -46,6 +46,8 @@ export interface ChatTurnStarted { readonly turnId: number; /** Absent from an older server; the live turn then shows no model until it is reloaded. */ readonly model?: string | null; + /** The connection the model sits on (ISC-511); absent from an older server. */ + readonly connection?: ChatConnectionName | null; } /** `step` — a tool call, sent once when it starts and once when it ends, under one ordinal. */ @@ -285,6 +287,8 @@ export interface TurnView { readonly sources: readonly ChatTurnSource[]; readonly replacesTurnId: number | null; readonly model: string | null; + /** The connection that answered it; null for a turn stored before the second connection existed (ISC-511). */ + readonly connection?: ChatConnectionName | null; readonly createdAt: string; /** Why it ended incomplete, as its live `error` event said; null otherwise and for a turn stored before it was kept (ISC-473). */ readonly endReason?: ChatErrorReason | null; @@ -313,10 +317,97 @@ export interface BulkDeleted { readonly deleted: readonly number[]; } +/** Which configured connection a model sits on — never its base URL. */ +export type ChatConnectionName = 'PRIMARY' | 'SECOND'; + +/** + * The connection a turn was answered on (ISC-511). A turn stored before the select (V37) carries none, + * and every turn then ran on the primary connection, so none reads as `PRIMARY`. + */ +export function answeredOn(turn: {readonly connection?: ChatConnectionName | null}): ChatConnectionName { + return turn.connection ?? 'PRIMARY'; +} + +/** One model a connection offers the chat — `ModelView` on the server. */ +export interface ChatModel { + /** The name as the connection reported it. */ + readonly name: string; + /** Whether a turn without a pick takes it; only ever true on the primary connection. */ + readonly defaultModel: boolean; +} + +/** + * One configured connection and the models it reported that can call tools — `ConnectionView`. + * One that did not answer is still listed: `reachable` false, its `reason`, and no models. + */ +export interface ChatConnection { + readonly name: ChatConnectionName; + /** The provider kind the connection speaks. */ + readonly kind: string; + /** Whether a call on it is paid for. */ + readonly billed: boolean; + readonly reachable: boolean; + /** Why it is not reachable, or why its models cannot be picked today; null when they can. */ + readonly reason: string | null; + readonly models: readonly ChatModel[]; +} + +/** `GET /api/v1/chat/models` — what the model select offers, the primary connection first (ISC-505). */ +export interface ChatModels { + readonly connections: readonly ChatConnection[]; + /** + * The model a turn without a pick takes: the configured chat model on the primary connection, as + * configured — listed or not. Null without a chat model; absent from a server older than the field. + */ + readonly defaultModelName?: string | null; +} + +/** + * The model a turn is asked on, named by its connection and its name (ISC-508, ISC-511). Sent with a + * question or a regenerate as both halves or neither: neither is the default on the primary connection. + */ +export interface ChatModelPick { + readonly connection: ChatConnectionName; + readonly model: string; +} + /** `GET /api/v1/chat/status` — the chat's own state, shown on the empty chat's ring (ISC-476). */ export interface ChatStatus { readonly model: string; readonly callsUsed: number; readonly callsLimit: number; readonly toolRounds: number; + /** Calls on the second connection today — counted against both ceilings; null without one (ISC-510). */ + readonly secondCalls?: number | null; + /** The second connection's own daily ceiling; null without a second connection (ISC-510). */ + readonly secondLimit?: number | null; +} + +/** + * Whether the status says the second connection's day is spent (ISC-510): its calls have reached its own + * ceiling. False while either number is unknown — without a status, or without a second connection. + */ +export function secondSpent(status: ChatStatus | null): boolean { + const calls = status?.secondCalls; + const limit = status?.secondLimit; + return calls != null && limit != null && calls >= limit; +} + +/** Why a connection's models cannot be picked now: a catalog key and what it interpolates. */ +export interface ChatPickReason { + readonly key: string; + readonly params: Readonly>; +} + +/** + * Why a connection's models cannot be picked now, or null when they can (ISC-510, ISC-511): it did not + * answer the catalog, the catalog named a reason, or — for the second connection — the status says its + * day is spent. The one rule: the select disables by it, and the store drops a pick by it. + */ +export function unpickableReason(connection: ChatConnection, status: ChatStatus | null): ChatPickReason | null { + const said = connection.reason?.trim() ?? ''; + if (!connection.reachable) return said === '' ? {key: 'chat.model.unreachableBare', params: {}} : {key: 'chat.model.unreachable', params: {reason: said}}; + if (said !== '') return {key: 'chat.model.unavailable', params: {reason: said}}; + if (connection.name === 'SECOND' && secondSpent(status)) return {key: 'chat.model.spent', params: {}}; + return null; } diff --git a/frontend/src/app/core/store/chat.events.ts b/frontend/src/app/core/store/chat.events.ts index 24cee1d..86fc644 100644 --- a/frontend/src/app/core/store/chat.events.ts +++ b/frontend/src/app/core/store/chat.events.ts @@ -1,6 +1,6 @@ import {type} from '@ngrx/signals'; import {eventGroup} from '@ngrx/signals/events'; -import {ChatContextItem, ChatEvent, ChatStatus, ConversationSummary, ConversationView} from '@core/model/chat'; +import {ChatContextItem, ChatEvent, ChatModelPick, ChatModels, ChatStatus, ConversationSummary, ConversationView} from '@core/model/chat'; /** * The chat drawer's events. @@ -63,11 +63,26 @@ export const chatEvents = eventGroup({ /** After the last batch landed; not after a failed one. */ bulkDeleteDone: type(), - /** The chat's own state for the empty chat's ring (ISC-476), asked each time its popover opens. */ + /** + * The chat's own state, asked each time the empty chat's popover opens (ISC-476) and after a turn + * or a catalog that names a second connection (ISC-510). It keeps the status it has until the new + * one arrives: a spent second connection stays disabled, and an open popover keeps its numbers. + */ statusRequested: type(), statusLoaded: type(), statusFailed: type(), + /** The model catalog, asked each time the drawer's composer opens (ISC-511). */ + modelsRequested: type(), + modelsLoaded: type(), + /** An unreadable catalog offers no select: the default answers as before. */ + modelsFailed: type(), + /** + * The model the open conversation's next question and regenerate are asked on (ISC-508, ISC-511); + * null is the default, picked back, which sends no pick. + */ + modelPicked: type(), + /** A new title; an empty one clears the name back to the derived one (ISC-449). */ renameRequested: type<{id: number; title: string}>(), renamed: type(), diff --git a/frontend/src/app/core/store/chat.store.ts b/frontend/src/app/core/store/chat.store.ts index 09c7ec7..a24250f 100644 --- a/frontend/src/app/core/store/chat.store.ts +++ b/frontend/src/app/core/store/chat.store.ts @@ -10,6 +10,7 @@ import { concat, concatMap, debounceTime, + defer, from, distinctUntilChanged, EMPTY, @@ -32,7 +33,10 @@ import { ChatContextItem, ChatError, ChatErrorReason, + ChatConnectionName, ChatEvent, + ChatModelPick, + ChatModels, ChatTurnSource, ChatStatus, ChatStep, @@ -46,6 +50,7 @@ import { sameContext, questionFits, TurnView, + unpickableReason, withPins, } from '@core/model/chat'; import {withAppDevtools} from '@core/store/devtools'; @@ -89,6 +94,8 @@ export interface LiveTurn { readonly startedAt: string; /** The model the server named in the `turn` event, so the live status reads as the stored one. */ readonly model: string | null; + /** The connection the `turn` event named beside the model (ISC-511). */ + readonly connection: ChatConnectionName | null; /** When its `done` or `error` event arrived; null while it streams. */ readonly finishedAt: string | null; } @@ -132,14 +139,29 @@ interface ChatState { * (ISC-453): the composer says why under the chips. Cleared by the next pin taken, an unpin, and a route. */ contextRefused: boolean; - /** The chat's own state for the empty chat's ring (ISC-476); null while it loads. */ + /** + * The chat's own state for the empty chat's ring (ISC-476); null until the first read answers. A read + * asked again keeps it until the new one arrives (ISC-510): a spent second connection stays disabled. + */ status: ChatStatus | null; + /** Whether a status read is in flight; the popover says "loading" only while there is no status yet. */ + statusLoading: boolean; statusFailed: boolean; /** * A bulk delete from request to its last batch (ISC-478). The list keeps its select mode and its * ticks until this says `idle` again, so a failed delete can be sent once more as it stood. */ bulk: 'idle' | 'deleting' | 'failed'; + /** What each configured connection offers the chat and the default's name (ISC-505, ISC-511); null until asked, or unreadable. */ + models: ChatModels | null; + /** + * The pick per conversation opened in this tab (ISC-511), by id; null is the default picked back, + * which holds over the last turn's model. In memory only, never in storage: a billed model is + * reached by a pick in that conversation and by nothing else (ISC-512). + */ + picks: Readonly>; + /** The pick under `?chat=new`, moved to the conversation its first question creates. */ + newPick: ChatModelPick | null; } /** What one bulk delete may name; the server refuses more (`BulkDelete`, `@Size(max = 500)`). */ @@ -163,8 +185,12 @@ const initialState: ChatState = { error: null, contextRefused: false, status: null, + statusLoading: false, statusFailed: false, bulk: 'idle', + models: null, + picks: {}, + newPick: null, }; const ID = /^[1-9]\d*$/; @@ -204,7 +230,8 @@ function routedState(chat: string | null, ctx: string | null, state: ChatState): if (chat === null) return {view: 'closed' as const, openId: null, context: []}; if (chat === 'list') return {view: 'list' as const, openId: null, context: [], listLoading: true, error: null}; const context = parseChatCtx(ctx); - if (chat === 'new') return {view: 'new' as const, openId: null, context, conversation: null, error: null}; + // A fresh conversation starts on the default; a pin added under `new` keeps the pick made there. + if (chat === 'new') return {view: 'new' as const, openId: null, context, conversation: null, error: null, ...(state.view === 'new' ? {} : {newPick: null})}; if (!ID.test(chat)) return {view: 'missing' as const, openId: null, context: []}; const id = Number(chat); // The conversation already held stays: a reopen, a screen change, or the swap from @@ -271,6 +298,7 @@ function startTurn(conversationId: number | null, question: string, replacesTurn stopPending: false, startedAt: new Date().toISOString(), model: null, + connection: null, finishedAt: null, }; } @@ -279,7 +307,7 @@ function startTurn(conversationId: number | null, question: string, replacesTurn function applyEvent(turn: LiveTurn, event: ChatEvent): LiveTurn { switch (event.event) { case 'turn': - return {...turn, turnId: event.data.turnId, model: event.data.model ?? null}; + return {...turn, turnId: event.data.turnId, model: event.data.model ?? null, connection: event.data.connection ?? null}; case 'step': { const step = event.data; const steps = [...turn.steps.filter((s) => s.ordinal !== step.ordinal), step]; @@ -311,6 +339,7 @@ function fold(conversation: ConversationView | null, live: LiveTurn | null): Con sources: live.sources, replacesTurnId: live.replacesTurnId, model: live.model, + connection: live.connection, createdAt: live.startedAt, endReason: live.error?.reason ?? null, finishedAt: live.finishedAt, @@ -336,6 +365,61 @@ function named( }; } +/** + * The listed model that is the default — the one a turn without a pick takes — or null when the catalog + * flags none. Never a stand-in: with none flagged, the default is not among the listed models, the select + * shows it as an option of its own, and every listed model is an explicit pick that is sent. + */ +function defaultPickOf(models: ChatModels | null): ChatModelPick | null { + for (const connection of models?.connections ?? []) { + const model = connection.models.find((m) => m.defaultModel); + if (model !== undefined) return {connection: connection.name, model: model.name}; + } + return null; +} + +/** + * The pick a stored conversation reopens on (ISC-512): the connection and model of its last turn, while + * the catalog still lists that model there. Null — the default — for a conversation without a turn, + * for a last turn stored before the select (no connection), for a model the catalog no longer has, and + * without a catalog: a pick nobody made in this tab is only taken up where the catalog vouches for it. + * Read off the conversation the server sends, never off storage: nothing outside it carries a pick. + */ +function lastTurnPick(conversation: ConversationView | null, models: ChatModels | null, status: ChatStatus | null): ChatModelPick | null { + const last = conversation?.turns.at(-1); + const connection = last?.connection ?? null; + const model = last?.model ?? null; + if (connection === null || model === null || models === null) return null; + return pickable({connection, model}, models, status); +} + +/** + * The pick itself while it can be asked on, else null, the default. Only a loaded catalog drops it: for + * a model it does not list on that connection, or a connection `unpickableReason` names a reason for — + * unreachable, given a reason, or the second one's day spent by the current status (ISC-510). A pick the + * select shows disabled is never sent: the server would refuse it, or end the turn on BUDGET. + * + *

Without a catalog — not loaded yet, or `modelsFailed` — there is nothing to judge by, and the pick + * the person made is kept and sent: the server validates it anyway and answers 400 for one that is gone. + * Only the effective pick falls back; the stored one is kept, so a connection that comes back, or a day + * that turns, brings it back. + */ +function pickable(pick: ChatModelPick | null, models: ChatModels | null, status: ChatStatus | null): ChatModelPick | null { + if (pick === null || models === null) return pick; + const connection = models.connections.find((c) => c.name === pick.connection); + const offered = connection !== undefined && unpickableReason(connection, status) === null && connection.models.some((m) => m.name === pick.model); + return offered ? pick : null; +} + +function samePick(a: ChatModelPick | null, b: ChatModelPick | null): boolean { + return a?.connection === b?.connection && a?.model === b?.model; +} + +/** Whether the catalog names a second connection, whose spent ceiling the status says (ISC-510). */ +function hasSecond(models: ChatModels | null): boolean { + return models?.connections.some((c) => c.name === 'SECOND') ?? false; +} + function status(error: unknown): number | null { return error instanceof HttpErrorResponse ? error.status : null; } @@ -364,7 +448,30 @@ export const ChatStore = signalStore( {providedIn: 'root'}, withState(initialState), withAppDevtools('chat'), - withComputed(({live, conversation, view, loading, streamOpen, context}) => ({ + withComputed(({live, conversation, view, loading, streamOpen, context, models, picks, newPick, openId, status}) => ({ + /** The listed model flagged as the default, or null when the catalog lists it nowhere. */ + defaultPick: computed(() => defaultPickOf(models())), + /** + * The model the open conversation's next question is asked on (ISC-511, ISC-512): its own pick in + * this tab, else — for a stored conversation — the model of its last turn, else null, the default. + * A new conversation has only its own pick, so it opens on the default whatever was picked elsewhere. + * Either is the default again while the current catalog does not offer it or the current status says + * its connection's day is spent; the pick itself is kept, so a connection that comes back, or a day + * that turns, brings it back. + */ + pick: computed(() => { + const id = openId(); + if (view() === 'new') return pickable(newPick(), models(), status()); + if (view() !== 'conversation' || id === null) return null; + const own = picks()[id]; + if (own !== undefined) return pickable(own, models(), status()); + const open = conversation(); + return open?.id === id ? lastTurnPick(open, models(), status()) : null; + }), + /** The connections a call on is paid for, so an answer can carry the marker (ISC-511). */ + billedConnections: computed(() => + (models()?.connections ?? []).filter((c) => c.billed).map((c) => c.name), + ), streaming: computed(() => live()?.state === 'STREAMING'), /** Every pin the next question is asked under, one chip each (ISC-446, ISC-451). */ contextItems: computed(() => effectiveContext({view: view(), context: context(), conversation: conversation()})), @@ -406,9 +513,18 @@ export const ChatStore = signalStore( withReducer( on(chatEvents.routed, ({payload: {chat, ctx}}, state) => ({...routedState(chat, ctx, state), contextRefused: false})), on(chatEvents.capabilityLoaded, ({payload}) => ({present: payload})), - on(chatEvents.statusRequested, () => ({status: null, statusFailed: false})), - on(chatEvents.statusLoaded, ({payload}) => ({status: payload})), - on(chatEvents.statusFailed, () => ({statusFailed: true})), + // No read clears the status it has: it holds until the answer, so nothing flickers back to enabled. + on(chatEvents.statusRequested, () => ({statusLoading: true, statusFailed: false})), + on(chatEvents.statusLoaded, ({payload}) => ({status: payload, statusLoading: false, statusFailed: false})), + on(chatEvents.statusFailed, () => ({statusLoading: false, statusFailed: true})), + on(chatEvents.modelsLoaded, ({payload}) => ({models: payload})), + on(chatEvents.modelsFailed, () => ({models: null})), + // Into the open conversation's pick, or the one `?chat=new` holds until its first question creates it. + on(chatEvents.modelPicked, ({payload}, state) => { + if (state.view === 'new') return {newPick: payload}; + if (state.view !== 'conversation' || state.openId === null) return {}; + return {picks: {...state.picks, [state.openId]: payload}}; + }), on(chatEvents.bulkDeleteRequested, () => ({bulk: 'deleting' as const})), on(chatEvents.bulkDeleteDone, () => ({bulk: 'idle' as const})), on(chatEvents.bulkDeleteFailed, () => ({bulk: 'failed' as const})), @@ -500,6 +616,7 @@ export const ChatStore = signalStore( ); return { conversations, + ...(pending !== null && state.newPick !== null ? {picks: {...state.picks, [payload.id]: state.newPick}} : {}), live: pending === null ? state.live : {...pending, conversationId: payload.id}, ...(state.view === 'new' && pending !== null ? {conversation, openId: payload.id} : {}), }; @@ -585,7 +702,46 @@ export const ChatStore = signalStore( catchError((error: unknown) => of(chatEvents.streamFailed(serverMessage(error as {error?: unknown}, 'error.chatStream')))), ); + /** + * The pick a request carries (ISC-508): none for the default — no pick, or the listed model the + * catalog flags as it — so a turn on it is asked exactly as before the select existed, and both + * halves of any other, the primary connection's first listed model included. + */ + const sentPick = (): [] | [ChatModelPick] => { + const pick = store.pick(); + return pick === null || samePick(pick, store.defaultPick()) ? [] : [pick]; + }; + return [ + // The catalog each time the drawer opens (ISC-511), asked by the App root's effect on `chatOpen` + // (app.ts): a connection that went away or came back since is seen then. Not from the composer, + // because the drawer stops checking its projected content while closed, so an effect in there + // never saw it close and asked only on the first open. Deferred, so a stand-in API without + // `models` fails into `modelsFailed`. + events.on(chatEvents.modelsRequested).pipe( + exhaustMap(() => + defer(() => api.models()).pipe( + map((catalog) => + chatEvents.modelsLoaded({ + connections: Array.isArray(catalog?.connections) ? catalog.connections : [], + defaultModelName: catalog?.defaultModelName ?? null, + }), + ), + catchError(() => of(chatEvents.modelsFailed())), + ), + ), + ), + // Whether the second connection's day is spent is the status's to say (ISC-510): asked with a + // catalog that names one, and again after each turn, which may have spent it. A refresh, not a + // first read: the status known so far holds until the answer, so nothing flickers back to enabled. + merge( + events.on(chatEvents.modelsLoaded), + events.on(chatEvents.streamEnded), + events.on(chatEvents.streamFailed), + ).pipe( + filter(() => hasSecond(store.models())), + map(() => chatEvents.statusRequested()), + ), // A fresh question the server named is stored: nothing for the composer to bring back. events.on(chatEvents.streamed).pipe( filter(({payload}) => payload.event === 'turn' && store.live()?.replacesTurnId === null), @@ -741,8 +897,9 @@ export const ChatStore = signalStore( ), ), - // The latest open wins: a quick hover off and on again asks twice, and the older answer, - // if it comes back last, is dropped rather than shown over the newer one. + // The latest read wins: a quick hover off and on again, or a popover opened while a turn's + // refresh is in flight, asks twice, and the older answer is dropped rather than shown over the + // newer one. Dropping it loses nothing: the status held meanwhile is the last one known. events.on(chatEvents.statusRequested).pipe( switchMap(() => api.status().pipe( @@ -786,14 +943,16 @@ export const ChatStore = signalStore( ).pipe( exhaustMap(({question, replaces}) => { const open = store.conversation(); + // An argument only when there is a pick: the default is asked exactly as before the select. + const pick = sentPick(); if (replaces !== null) { - return open === null || store.live()?.replacesTurnId !== replaces ? EMPTY : turn(api.regenerate(open.id, replaces)); + return open === null || store.live()?.replacesTurnId !== replaces ? EMPTY : turn(api.regenerate(open.id, replaces, ...pick)); } - if (open !== null) return turn(api.ask(open.id, question as string)); + if (open !== null) return turn(api.ask(open.id, question as string, ...pick)); // The first question under `?chat=new`: the conversation exists from here on. // It carries the pins `?chatCtx` holds, so the conversation is stored with them (ISC-446). return api.create(store.context()).pipe( - switchMap((created) => concat(of(chatEvents.created(created)), turn(api.ask(created.id, question as string)))), + switchMap((created) => concat(of(chatEvents.created(created)), turn(api.ask(created.id, question as string, ...pick)))), catchError((error: unknown) => of(chatEvents.streamFailed(serverMessage(error as {error?: unknown}, 'error.chatCreate')))), ); }), diff --git a/frontend/src/app/features/chat/chat-answer/chat-answer.html b/frontend/src/app/features/chat/chat-answer/chat-answer.html index 3f956c8..b86cd57 100644 --- a/frontend/src/app/features/chat/chat-answer/chat-answer.html +++ b/frontend/src/app/features/chat/chat-answer/chat-answer.html @@ -21,6 +21,17 @@ } +@if (model(); as name) { + +

+ {{ 'chat.answer.model' | transloco }} + {{ name }} + @if (billed()) { + · {{ 'chat.model.billed' | transloco }} + } +

+} + @if (finished()) {
+ @if (showModels() && unavailable().length > 0) { +

+ @for (group of unavailable(); track group.name) { + {{ 'chat.model.note' | transloco: { connection: (group.labelKey | transloco), reason: (group.reason!.key | transloco: group.reason!.params) } }} + } +

+ }

{{ 'chat.disclaimer' | transloco }}

{{ announcement() }}

diff --git a/frontend/src/app/features/chat/chat-composer/chat-composer.ts b/frontend/src/app/features/chat/chat-composer/chat-composer.ts index 00be77b..9aa0794 100644 --- a/frontend/src/app/features/chat/chat-composer/chat-composer.ts +++ b/frontend/src/app/features/chat/chat-composer/chat-composer.ts @@ -4,7 +4,16 @@ import {TranslocoPipe, TranslocoService} from '@jsverse/transloco'; import {injectDispatch} from '@ngrx/signals/events'; import {catchError, debounceTime, map, of, Subject, switchMap} from 'rxjs'; import {ShortlistApi} from '@core/api/shortlist.api'; -import {ChatContextItem, MAX_PINNED_OFFERS, pinnedOfferCount, QUESTION_MAX_LENGTH, questionFits} from '@core/model/chat'; +import { + ChatConnectionName, + ChatContextItem, + ChatPickReason, + MAX_PINNED_OFFERS, + pinnedOfferCount, + QUESTION_MAX_LENGTH, + questionFits, + unpickableReason, +} from '@core/model/chat'; import {ShortlistEntry} from '@core/model/shortlist-entry'; import {chatEvents} from '@core/store/chat.events'; import {trackUnsavedWork} from '@core/unsaved/unsaved-work'; @@ -38,6 +47,45 @@ function mentionAt(text: string, caret: number): Mention | null { return {start: before.length - match[2].length - 1, query: match[2]}; } +/** One `` of the model select: a connection, its options, and why they are disabled if they are. */ +interface ModelGroup { + readonly name: ChatConnectionName; + readonly labelKey: string; + readonly reason: ChatPickReason | null; + readonly options: readonly {readonly value: string; readonly model: string; readonly billed: boolean}[]; +} + +/** The catalog keys naming each connection: its configured role, never its host or vendor. */ +const CONNECTION_KEYS: Record = { + PRIMARY: 'chat.model.connection.PRIMARY', + SECOND: 'chat.model.connection.SECOND', +}; + +/** + * The option standing for the default when the catalog lists it nowhere (ISC-511): picking it sends no + * pick. Holds no colon, so it is never read as a connection and a model. + */ +const DEFAULT_OPTION = 'default'; + +/** + * The default's own option: its label as a catalog key, whether the primary connection it runs on is + * billed, and why that connection cannot be asked now — which disables the option, the way it disables + * the connection's group — or null. + */ +interface DefaultOption { + readonly key: string; + readonly params: Readonly>; + readonly billed: boolean; + readonly reason: ChatPickReason | null; +} + +/** A pick as an option's value. The connection name holds no colon, so the first one splits it. */ +function optionValue(connection: ChatConnectionName, model: string): string { + return `${connection}:${model}`; +} + +let nextModelSelectId = 0; + /** Whether the browser grows a textarea with its content by itself; otherwise a script does. */ const GROWS_ITSELF = typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('field-sizing', 'content'); @@ -112,6 +160,57 @@ export class ChatComposer { protected readonly showCount = computed(() => this.offerCount() >= COUNT_FROM); protected readonly maxOffers = MAX_PINNED_OFFERS; + /** + * The model select (ISC-511): one group per connection the catalog lists, billed options marked in + * words, and a spent or unreachable connection's options disabled with the reason beside the select. + */ + protected readonly modelSelectId = `lg-chat-model-${nextModelSelectId++}`; + protected readonly modelNoteId = `${this.modelSelectId}-note`; + protected readonly modelGroups = computed(() => + (this.store.models()?.connections ?? []).map((connection) => ({ + name: connection.name, + labelKey: CONNECTION_KEYS[connection.name], + reason: unpickableReason(connection, this.store.status()), + options: connection.models.map((model) => ({ + value: optionValue(connection.name, model.name), + model: model.name, + billed: connection.billed, + })), + })), + ); + /** + * The default as an option of its own, first and outside the groups, when no listed model is flagged + * as it: the configured model by name, or just "Default" when the server names none. Null when a + * listed model is the default, which then stands for it. + * + *

Disabled while the primary connection it runs on has a reason not to be asked; that reason is in + * the note the select is described by already, as the primary connection's group carries it. It stays + * selected while it is the effective pick: the store never moves a question onto another connection by + * itself, which may be billed, so the person picks one — and a disabled default left unselected would + * have the browser show the first enabled option while the question still went to the default. + */ + protected readonly defaultOption = computed(() => { + const models = this.store.models(); + if (models === null || this.store.defaultPick() !== null) return null; + const name = models.defaultModelName?.trim() ?? ''; + const primary = models.connections.find((c) => c.name === 'PRIMARY'); + const billed = primary?.billed ?? false; + const reason = primary === undefined ? null : unpickableReason(primary, this.store.status()); + const option: DefaultOption = + name === '' ? {key: 'chat.model.defaultBare', params: {}, billed, reason} : {key: 'chat.model.default', params: {name}, billed, reason}; + return option; + }); + protected readonly defaultValue = DEFAULT_OPTION; + /** One model in all is no choice: the select appears only when there is something to pick. */ + protected readonly showModels = computed( + () => this.modelGroups().reduce((sum, group) => sum + group.options.length, this.defaultOption() === null ? 0 : 1) > 1, + ); + protected readonly unavailable = computed(() => this.modelGroups().filter((group) => group.reason !== null)); + protected readonly pickValue = computed(() => { + const pick = this.store.pick() ?? this.store.defaultPick(); + return pick === null ? DEFAULT_OPTION : optionValue(pick.connection, pick.model); + }); + /** * The `@` search (ISC-453): the `@query` at the caret, what the offers endpoint found for it — * null until it answered, so "nothing matches" is never said before it was asked — and the row @@ -296,6 +395,19 @@ export class ChatComposer { this.searches.next(mention.query); } + /** The pick for this conversation's next question and regenerate (ISC-508, ISC-511). */ + protected chooseModel(event: Event): void { + const value = (event.target as HTMLSelectElement).value; + if (value === DEFAULT_OPTION) { + this.dispatch.modelPicked(null); + return; + } + const split = value.indexOf(':'); + const connection = value.slice(0, split); + if (split < 0 || !(connection in CONNECTION_KEYS)) return; + this.dispatch.modelPicked({connection: connection as ChatConnectionName, model: value.slice(split + 1)}); + } + /** A chip's ✕: out of the URL, and out of the stored conversation when there is one (ISC-451). */ protected unpin(item: ChatContextItem): void { this.dispatch.contextRemoved(item); diff --git a/frontend/src/app/features/chat/chat-model-select.browser.spec.ts b/frontend/src/app/features/chat/chat-model-select.browser.spec.ts new file mode 100644 index 0000000..9ec2deb --- /dev/null +++ b/frontend/src/app/features/chat/chat-model-select.browser.spec.ts @@ -0,0 +1,726 @@ +import {Component} from '@angular/core'; +import {provideHttpClient} from '@angular/common/http'; +import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {provideRouter, Router} from '@angular/router'; +import {page} from 'vitest/browser'; +import {TranslocoTestingModule} from '@jsverse/transloco'; +import {provideTranslocoMessageformat} from '@jsverse/transloco-messageformat'; +import {injectDispatch} from '@ngrx/signals/events'; +import {of, Subject} from 'rxjs'; +import {ChatApi} from '@core/api/chat.api'; +import {chatEvents} from '@core/store/chat.events'; +import {ChatEvent, ChatModels, ChatStatus, ConversationView, TurnView} from '@core/model/chat'; +import {App} from '../../app'; +import en from '../../../../public/i18n/en.json'; + +@Component({template: '

screen

'}) +class Screen {} + +/** Two connections: the primary billed with two models, the second unbilled with one, its day's calls spent. */ +const CATALOG: ChatModels = { + connections: [ + { + name: 'PRIMARY', + kind: 'local', + billed: true, + reachable: true, + reason: null, + models: [ + {name: 'model-a', defaultModel: true}, + {name: 'model-b', defaultModel: false}, + ], + }, + {name: 'SECOND', kind: 'local', billed: false, reachable: true, reason: null, models: [{name: 'model-c', defaultModel: false}]}, + ], +}; + +const SPENT: ChatStatus = {model: 'model-a', callsUsed: 30, callsLimit: 200, toolRounds: 6, secondCalls: 20, secondLimit: 20}; + +/** + * The model select beside the composer (ISC-511): the catalog grouped by connection, the billed + * marker on billed options, a spent or unreachable connection's options disabled with the reason + * in words, the pick sent with the next question, and the model named under the answer it wrote. + * + *

In a real browser, because a native `