From b590b652ce4b100f40683502502fa5dd8d808022 Mon Sep 17 00:00:00 2001 From: Marcello Muscara Date: Mon, 12 Oct 2026 14:07:34 +0200 Subject: [PATCH 1/4] Pick the chat's model per question, from a local and an optional hosted connection The chat answered with one model of the single provider every stage shares. An optional second connection, `chat.connection` (`CHAT_LLM_*`), sits beside `llm` and only the chat reads it. The drawer's select lists the tool-capable models each connection reports, read from the connection rather than from an allowlist; a turn may name only a model the server listed itself, and anything else is a 400 before a model is called. - The pick may change between questions and on regenerate; a new conversation always starts on the configured default, a reopened one on its last turn's model. Nothing is kept in the browser's storage, and there is no fallback between models. - `chat_turn.connection` (V37) records who answered; the turn event and the stored turn name model and connection, and every answer shows its model. - The second connection has a daily ceiling of its own (default 50), taken in one transaction with the chat's, so a refused call counts on neither; `GET /chat/status` names both counters. - `billed` on each connection marks paid models in the select and under the answer; empty means billed for `anthropic` only. - The masker covers both connections; the startup banner shows the second connection with its key masked. Suggestions and the semantic tool stay on the primary connection. New endpoint `GET /api/v1/chat/models`. Reasoning in docs/decisions/chat.md. --- .env.example | 7 + CHANGELOG.md | 12 + .../leadgen/ConfigurationBanner.java | 32 ++ .../leadgen/LeadGenRuntimeHints.java | 10 + .../codeministry/leadgen/chat/ChatBudget.java | 111 +++- .../leadgen/chat/ChatStatusView.java | 13 +- .../leadgen/chat/ChatTurnService.java | 107 +++- .../leadgen/chat/ChatTurnStarted.java | 9 +- .../leadgen/chat/ConversationRepository.java | 57 +- .../codeministry/leadgen/chat/ModelPick.java | 39 ++ .../de/codeministry/leadgen/chat/NewTurn.java | 17 +- .../leadgen/chat/RegenerateTurn.java | 24 + .../codeministry/leadgen/chat/TurnView.java | 34 +- .../leadgen/chat/catalog/AnthropicModels.java | 75 +++ .../chat/catalog/ChatModelCatalog.java | 214 ++++++++ .../leadgen/chat/catalog/ChatModelsView.java | 20 + .../leadgen/chat/catalog/ConnectionView.java | 26 + .../leadgen/chat/catalog/ModelView.java | 18 + .../leadgen/chat/catalog/OllamaModels.java | 83 +++ .../chat/catalog/OpenAiCompatibleModels.java | 53 ++ .../leadgen/config/ConfigLoader.java | 53 ++ .../leadgen/config/model/PipelineConfig.java | 70 ++- .../codeministry/leadgen/llm/Connections.java | 113 ++++ .../leadgen/web/ChatController.java | 75 ++- .../leadgen/workflow/WorkflowCatalog.java | 4 + .../migration/V37__chat_turn_connection.sql | 24 + .../src/main/resources/leadgen/pipeline.yaml | 21 + .../leadgen/ConfigurationBannerTest.java | 51 ++ .../leadgen/chat/AnthropicStub.java | 270 +++++++++ .../leadgen/chat/ChatBudgetTest.java | 176 ++++++ .../chat/ChatConnectionConfigTest.java | 301 ++++++++++ .../leadgen/chat/ChatModelCatalogTest.java | 246 +++++++++ .../leadgen/chat/ChatModelSelectTest.java | 517 ++++++++++++++++++ .../leadgen/chat/ChatRedactionTest.java | 78 ++- .../leadgen/chat/ChatStreamQueueWaitTest.java | 6 +- .../codeministry/leadgen/chat/ModelStub.java | 104 +++- .../leadgen/config/ConfigFixtures.java | 7 + .../leadgen/web/ChatControllerTest.java | 200 ++++++- docs/CONFIGURATION.md | 17 + docs/decisions/chat.md | 35 ++ frontend/public/i18n/de.json | 21 +- frontend/public/i18n/en.json | 21 +- frontend/src/app/core/api/chat-stub.ts | 22 +- frontend/src/app/core/api/chat.api.spec.ts | 31 ++ frontend/src/app/core/api/chat.api.ts | 33 +- frontend/src/app/core/model/chat.ts | 49 ++ frontend/src/app/core/store/chat.events.ts | 10 +- frontend/src/app/core/store/chat.store.ts | 122 ++++- .../chat/chat-answer/chat-answer.html | 11 + .../features/chat/chat-answer/chat-answer.ts | 4 + .../chat/chat-composer/chat-composer.css | 32 ++ .../chat/chat-composer/chat-composer.html | 37 ++ .../chat/chat-composer/chat-composer.ts | 96 +++- .../chat/chat-model-select.browser.spec.ts | 468 ++++++++++++++++ .../chat-panel/chat-panel.browser.spec.ts | 24 +- .../features/chat/chat-panel/chat-panel.html | 2 + .../chat-status-popover.html | 4 + .../chat-status-popover.ts | 9 + .../app/layout/app-header/app-header.spec.ts | 4 +- 59 files changed, 4244 insertions(+), 85 deletions(-) create mode 100644 backend/src/main/java/de/codeministry/leadgen/chat/ModelPick.java create mode 100644 backend/src/main/java/de/codeministry/leadgen/chat/RegenerateTurn.java create mode 100644 backend/src/main/java/de/codeministry/leadgen/chat/catalog/AnthropicModels.java create mode 100644 backend/src/main/java/de/codeministry/leadgen/chat/catalog/ChatModelCatalog.java create mode 100644 backend/src/main/java/de/codeministry/leadgen/chat/catalog/ChatModelsView.java create mode 100644 backend/src/main/java/de/codeministry/leadgen/chat/catalog/ConnectionView.java create mode 100644 backend/src/main/java/de/codeministry/leadgen/chat/catalog/ModelView.java create mode 100644 backend/src/main/java/de/codeministry/leadgen/chat/catalog/OllamaModels.java create mode 100644 backend/src/main/java/de/codeministry/leadgen/chat/catalog/OpenAiCompatibleModels.java create mode 100644 backend/src/main/java/de/codeministry/leadgen/llm/Connections.java create mode 100644 backend/src/main/resources/db/migration/V37__chat_turn_connection.sql create mode 100644 backend/src/test/java/de/codeministry/leadgen/chat/AnthropicStub.java create mode 100644 backend/src/test/java/de/codeministry/leadgen/chat/ChatConnectionConfigTest.java create mode 100644 backend/src/test/java/de/codeministry/leadgen/chat/ChatModelCatalogTest.java create mode 100644 backend/src/test/java/de/codeministry/leadgen/chat/ChatModelSelectTest.java create mode 100644 frontend/src/app/features/chat/chat-model-select.browser.spec.ts diff --git a/.env.example b/.env.example index b0145deb..b709c6ac 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 00945c45..13a7c1b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ may change in any release. See the status note in the README. ## [Unreleased] +### 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. + ## [0.5.4] — 2026-10-11 The workflow screen's live run holds through a dropped heartbeat and a skewed clock, links in the diff --git a/backend/src/main/java/de/codeministry/leadgen/ConfigurationBanner.java b/backend/src/main/java/de/codeministry/leadgen/ConfigurationBanner.java index b1e842a7..87a08cd9 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 bf4d70fc..3ab1c6ce 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 c1cd3552..c83d7234 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 c91539ea..7be6bde3 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 e9a62cc3..dbaffc8c 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 51d9ab76..9b63eded 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 3eba0423..6aae5652 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,13 +108,21 @@ 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 """; + private static final String LAST_PICK = """ + SELECT connection, model + FROM chat_turn + WHERE conversation_id = :conversation + ORDER BY ordinal DESC + LIMIT 1 + """; + private static final String TOUCH = """ UPDATE chat_conversation SET title = CASE WHEN title = '' THEN :title ELSE title END, @@ -372,7 +381,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 +398,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 +408,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(); @@ -436,6 +454,20 @@ public OffsetDateTime now() { } /** The question a turn of this conversation asked; empty for a turn of another one. */ + /** + * The pick the conversation's last turn was asked with, so a reopened conversation stays on its + * model (spec 025, ISC-512). The default when the conversation has no turn yet, or when its last + * turn was stored before V37 gave a turn its connection. + */ + public ModelPick lastPick(long conversationId) { + return jdbc.sql(LAST_PICK) + .param("conversation", conversationId) + .query((rs, index) -> new ModelPick(rs.getString("connection"), rs.getString("model"))) + .optional() + .filter(pick -> !pick.isPartial()) + .orElse(ModelPick.DEFAULT); + } + public Optional question(long conversationId, long turnId) { return jdbc.sql("SELECT question FROM chat_turn WHERE id = :turn AND conversation_id = :conversation") .param("turn", turnId) @@ -670,6 +702,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 +965,7 @@ private record StoredTurn( ChatTurnState state, Long replaces, String model, + String connection, java.time.Instant createdAt, List citations, ChatErrorReason endReason, @@ -939,7 +973,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 00000000..e309a639 --- /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 14bb214f..4e2639bd 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 00000000..c4a58800 --- /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 aeca53bb..57579902 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 00000000..02eb7463 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/AnthropicModels.java @@ -0,0 +1,75 @@ +/* + * 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; + String key = llm.apiKey() == null ? "" : llm.apiKey(); + List names = new ArrayList<>(); + String after = null; + for (int page = 0; page < MAX_PAGES; page++) { + String url = + after == null ? models : models + "&after_id=" + URLEncoder.encode(after, StandardCharsets.UTF_8); + JsonNode body = discovery.send(discovery + .request(URI.create(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 00000000..8e71066d --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ChatModelCatalog.java @@ -0,0 +1,214 @@ +/* + * 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.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 java.io.IOException; +import java.net.ConnectException; +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.Optional; +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, no answer within {@link #DISCOVERY_TIMEOUT} — becomes + * that connection listed as unreachable, with its reason and no models. + * + *

Cached per configuration snapshot for {@link #TTL}. {@link #models()} refreshes a + * stale list; a reload hands out a new snapshot object, and the list read under the old one goes + * with it. {@link #contains} reads the cached list and never fetches, because a turn must not wait + * on a model server's listing before it starts. + */ +@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); + + private final ConfigRegistry config; + private final Discovery discovery = new Discovery(); + private final AtomicReference cached = new AtomicReference<>(); + + /** Every configured connection with its tool-capable models, from the cache while it is fresh. */ + public synchronized ChatModelsView models() { + ConfigSnapshot snapshot = config.snapshot(); + Cached current = cached.get(); + long now = System.nanoTime(); + if (current != null && current.snapshot() == snapshot && now - current.readAt() < TTL.toNanos()) { + return current.view(); + } + ChatModelsView view = read(snapshot.application()); + cached.set(new Cached(snapshot, now, view)); + return view; + } + + /** + * 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 ChatModelsView read(PipelineConfig pipeline) { + Optional defaultModel = + ModelChoice.chat(pipeline.llm() == null ? null : pipeline.llm().models()); + List connections = new ArrayList<>(2); + for (Connection connection : Connections.all(pipeline)) { + connections.add( + read(connection, connection.name() == Connections.Name.PRIMARY ? defaultModel : Optional.empty())); + } + return new ChatModelsView(List.copyOf(connections)); + } + + private ConnectionView read(Connection connection, Optional defaultModel) { + String name = connection.name().name(); + try { + Lister lister = lister(connection.kind()); + if (connection.llm().baseUrl() == null || connection.llm().baseUrl().isBlank()) { + throw new IOException("base_url is not set"); + } + List models = lister.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, "the listing was interrupted"); + } catch (IOException | RuntimeException e) { + String reason = reason(e); + log.warn("The {} connection ({}) lists no models for the chat: {}", name, connection.kind(), reason); + return unreachable(connection, reason); + } + } + + private Lister lister(String kind) throws IOException { + return switch (kind == null ? "" : kind) { + case ChatModels.OLLAMA -> new OllamaModels(discovery); + case ChatModels.ANTHROPIC -> new AnthropicModels(discovery); + case ChatModels.OPENAI_COMPATIBLE -> new OpenAiCompatibleModels(discovery); + default -> throw new IOException("provider kind '" + kind + "' cannot list its models"); + }; + } + + /** + * 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, in words the select can show; never the key, never a stack trace. */ + private static String reason(Exception e) { + if (e instanceof HttpTimeoutException) { + return "no answer within " + DISCOVERY_TIMEOUT.toSeconds() + " s"; + } + if (e instanceof ConnectException) { + return "the connection was refused"; + } + if (e instanceof IllegalArgumentException) { + return "base_url is not a valid URL"; + } + String message = e.getMessage(); + return message == null || message.isBlank() ? e.getClass().getSimpleName() : message; + } + + /** One list as read under one configuration snapshot, and when. */ + private record Cached(ConfigSnapshot snapshot, long readAt, ChatModelsView view) {} + + /** 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 client every adapter shares, bounded by {@link #DISCOVERY_TIMEOUT}. */ + static final class Discovery { + + private final HttpClient http = 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"); + } + + /** The body as JSON, or an {@link IOException} naming the path and the status for a non-2xx. */ + JsonNode send(HttpRequest request) throws IOException, InterruptedException { + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() / 100 != 2) { + throw new IOException( + request.method() + " " + request.uri().getPath() + " answered HTTP " + response.statusCode()); + } + return json.readTree(response.body()); + } + + /** {@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 00000000..aa47e947 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ChatModelsView.java @@ -0,0 +1,20 @@ +/* + * 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. + */ +public record ChatModelsView(List connections) {} 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 00000000..60db57e9 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/ConnectionView.java @@ -0,0 +1,26 @@ +/* + * 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 it is not reachable, or why its models cannot be picked today (its ceiling is + * spent); {@code null} when they can. + * @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 00000000..4937f569 --- /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 00000000..d8806a61 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/OllamaModels.java @@ -0,0 +1,83 @@ +/* + * 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 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, 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 { + + private final ChatModelCatalog.Discovery discovery; + + @Override + public List list(PipelineConfig.Llm llm) throws IOException, InterruptedException { + String root = nativeRoot(llm.baseUrl()); + JsonNode tags = discovery.send( + discovery.request(URI.create(root + "/api/tags")).GET().build()); + List capable = new ArrayList<>(); + for (JsonNode entry : tags.path("models")) { + String name = entry.path("name").asText(""); + if (!name.isBlank() && callsTools(root, name)) { + capable.add(name); + } + } + return capable; + } + + /** + * Whether {@code /api/show} lists {@code tools} for {@code name}. One model the server cannot + * describe is left out with a WARN rather than taking the whole connection down: the list it + * came from did answer. + */ + private boolean callsTools(String root, String name) throws InterruptedException { + String body = JsonNodeFactory.instance.objectNode().put("model", name).toString(); + try { + JsonNode show = discovery.send(discovery + .request(URI.create(root + "/api/show")) + .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 e) { + log.warn( + "Ollama could not describe model '{}', so it is not offered to the chat: {}", name, e.getMessage()); + 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 00000000..09851dd1 --- /dev/null +++ b/backend/src/main/java/de/codeministry/leadgen/chat/catalog/OpenAiCompatibleModels.java @@ -0,0 +1,53 @@ +/* + * 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.http.HttpRequest; +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 { + HttpRequest.Builder request = + discovery.request(URI.create(ChatModelCatalog.Discovery.trimSlash(llm.baseUrl()) + "/models")); + // A local server wants no key, and an empty bearer is a header some of them refuse. + if (llm.apiKey() != null && !llm.apiKey().isBlank()) { + request.header("Authorization", "Bearer " + llm.apiKey()); + } + JsonNode body = discovery.send(request.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 4018d8ad..1a428390 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/model/PipelineConfig.java b/backend/src/main/java/de/codeministry/leadgen/config/model/PipelineConfig.java index b888bba3..25e004c0 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 00000000..621c5557 --- /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/web/ChatController.java b/backend/src/main/java/de/codeministry/leadgen/web/ChatController.java index 0236c5d7..6632fcf9 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 23b29fd5..72cca2e1 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/db/migration/V37__chat_turn_connection.sql b/backend/src/main/resources/db/migration/V37__chat_turn_connection.sql new file mode 100644 index 00000000..a81e5720 --- /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 00ad796d..33315f94 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 c6138258..4f1578e4 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 00000000..fc1c6e9b --- /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 c6f3716e..d01c24f4 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 00000000..29ea3505 --- /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 00000000..c293baed --- /dev/null +++ b/backend/src/test/java/de/codeministry/leadgen/chat/ChatModelCatalogTest.java @@ -0,0 +1,246 @@ +/* + * 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 com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +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.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * 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. + */ +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(); + } + + private final ConfigRegistry registry = Mockito.mock(ConfigRegistry.class); + + @AfterAll + static void stop() { + PRIMARY.stop(); + SECOND.stop(); + } + + @BeforeEach + void reset() { + 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"); + 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()).contains("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"))); + } + + 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.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) { + ConfigSnapshot snapshot = snapshot(llm, second); + given(registry.snapshot()).willReturn(snapshot); + } + + 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); + } +} 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 00000000..edacf0a8 --- /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 77986322..80ee5252 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 42327865..d113a38d 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 18d91c90..d4bdd3d2 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/config/ConfigFixtures.java b/backend/src/test/java/de/codeministry/leadgen/config/ConfigFixtures.java index 83034a66..e006999a 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 5e780353..68e6d06d 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,188 @@ 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, "answered HTTP 500", List.of())))); + + 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("answered HTTP 500"); + models.extractingPath("$.connections[1].models").asArray().isEmpty(); + } + + /** 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)))))); + 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/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 7babd217..0d646c3a 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/decisions/chat.md b/docs/decisions/chat.md index 44121ac2..43f31354 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 diff --git a/frontend/public/i18n/de.json b/frontend/public/i18n/de.json index b5a09583..072e60e6 100644 --- a/frontend/public/i18n/de.json +++ b/frontend/public/i18n/de.json @@ -957,6 +957,22 @@ "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", + "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 +980,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,6 +1071,8 @@ "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." diff --git a/frontend/public/i18n/en.json b/frontend/public/i18n/en.json index 939d5573..ed6a8101 100644 --- a/frontend/public/i18n/en.json +++ b/frontend/public/i18n/en.json @@ -957,6 +957,22 @@ "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", + "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 +980,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,6 +1071,8 @@ "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." diff --git a/frontend/src/app/core/api/chat-stub.ts b/frontend/src/app/core/api/chat-stub.ts index 5b9b8d12..83e8065a 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,26 @@ 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}, + ], + }, + ], +}; + /** 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 dc27ffc4..8637602a 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 2c797c57..ffdaf77b 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 8a252660..cae5eb06 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,55 @@ export interface BulkDeleted { readonly deleted: readonly number[]; } +/** Which configured connection a model sits on — never its base URL. */ +export type ChatConnectionName = 'PRIMARY' | 'SECOND'; + +/** 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 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; } diff --git a/frontend/src/app/core/store/chat.events.ts b/frontend/src/app/core/store/chat.events.ts index 24cee1d6..0328797f 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. @@ -68,6 +68,14 @@ export const chatEvents = eventGroup({ 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). */ + 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 09c7ec7a..d6307953 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, @@ -89,6 +93,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; } @@ -140,6 +146,15 @@ interface ChatState { * 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 (ISC-505, ISC-511); null until asked, or unreadable. */ + models: ChatModels | null; + /** + * The pick per conversation opened in this tab (ISC-511), by id. 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)`). */ @@ -165,6 +180,9 @@ const initialState: ChatState = { status: null, statusFailed: false, bulk: 'idle', + models: null, + picks: {}, + newPick: null, }; const ID = /^[1-9]\d*$/; @@ -204,7 +222,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 +290,7 @@ function startTurn(conversationId: number | null, question: string, replacesTurn stopPending: false, startedAt: new Date().toISOString(), model: null, + connection: null, finishedAt: null, }; } @@ -279,7 +299,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 +331,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 +357,37 @@ function named( }; } +/** The default the select shows on a new conversation: the primary connection's default model. */ +function defaultPickOf(models: ChatModels | null): ChatModelPick | null { + const primary = models?.connections.find((c) => c.name === 'PRIMARY'); + const model = primary?.models.find((m) => m.defaultModel) ?? primary?.models[0]; + return primary === undefined || model === undefined ? null : {connection: primary.name, model: model.name}; +} + +/** + * 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), and for a model the catalog no longer has. + * Read off the conversation the server sends, never off storage: nothing outside it carries a pick. + */ +function lastTurnPick(conversation: ConversationView | null, models: ChatModels | null): ChatModelPick | null { + const last = conversation?.turns.at(-1); + const connection = last?.connection ?? null; + const model = last?.model ?? null; + if (connection === null || model === null) return null; + const listed = models?.connections.some((c) => c.name === connection && c.models.some((m) => m.name === model)) ?? false; + return listed ? {connection, model} : 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 +416,25 @@ 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}) => ({ + 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 the default. A new + * conversation has only its own pick, so it opens on the default whatever was picked elsewhere. + * Null without a catalog, which leaves the server's default to answer. + */ + pick: computed(() => { + const id = openId(); + if (view() === 'new') return newPick() ?? defaultPickOf(models()); + if (view() !== 'conversation' || id === null) return defaultPickOf(models()); + const open = conversation(); + return picks()[id] ?? (open?.id === id ? lastTurnPick(open, models()) : null) ?? defaultPickOf(models()); + }), + /** 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()})), @@ -409,6 +479,14 @@ export const ChatStore = signalStore( on(chatEvents.statusRequested, () => ({status: null, statusFailed: false})), on(chatEvents.statusLoaded, ({payload}) => ({status: payload})), on(chatEvents.statusFailed, () => ({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 +578,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 +664,36 @@ 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, so a turn on it is asked exactly + * as before the select existed, and both halves of any other. + */ + const sentPick = (): [] | [ChatModelPick] => { + const pick = store.pick(); + return pick === null || samePick(pick, store.defaultPick()) ? [] : [pick]; + }; + return [ + // The catalog each time the composer opens (ISC-511): a connection that went away or came back + // since is seen then. 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 : []})), + 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. + 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), @@ -786,14 +894,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 3f956c8a..b86cd57a 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 00be77b6..04145ed4 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 { + ChatConnection, + ChatConnectionName, + ChatContextItem, + ChatStatus, + MAX_PINNED_OFFERS, + pinnedOfferCount, + QUESTION_MAX_LENGTH, + questionFits, +} 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,47 @@ function mentionAt(text: string, caret: number): Mention | null { return {start: before.length - match[2].length - 1, query: match[2]}; } +/** A catalog key and what it interpolates: why a connection's models cannot be picked now. */ +interface Reason { + readonly key: string; + readonly params: Readonly>; +} + +/** 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: Reason | 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', +}; + +/** 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}`; +} + +/** + * Why a connection's models cannot be picked, or null when they can (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. + */ +function reasonOf(connection: ChatConnection, status: ChatStatus | null): Reason | 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}}; + const calls = status?.secondCalls; + const limit = status?.secondLimit; + if (connection.name === 'SECOND' && calls != null && limit != null && calls >= limit) return {key: 'chat.model.spent', params: {}}; + return null; +} + +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 +162,32 @@ 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: reasonOf(connection, this.store.status()), + options: connection.models.map((model) => ({ + value: optionValue(connection.name, model.name), + model: model.name, + billed: connection.billed, + })), + })), + ); + /** 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, 0) > 1); + protected readonly unavailable = computed(() => this.modelGroups().filter((group) => group.reason !== null)); + protected readonly pickValue = computed(() => { + const pick = this.store.pick(); + return pick === null ? null : 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 @@ -129,6 +205,15 @@ export class ChatComposer { private readonly searches = new Subject(); constructor() { + // The catalog each time the drawer opens, so a connection that went away or came back is seen. Not + // when the composer is built: it stands in the closed drawer on every page, and a page load is no + // reason to ask each connection for its models. Without a chat model nothing is asked (ISC-513). + let wasOpen = false; + effect(() => { + const open = this.store.present() === true && this.store.view() !== 'closed'; + if (open && !wasOpen) untracked(() => this.dispatch.modelsRequested()); + wasOpen = open; + }); // A question typed and not yet sent is lost by a redirect to the sign-in; it holds one. Not the // kept question put back here: it outlives the redirect and comes back after the sign-in. trackUnsavedWork(() => { @@ -296,6 +381,15 @@ 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; + 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 00000000..8da0ab44 --- /dev/null +++ b/frontend/src/app/features/chat/chat-model-select.browser.spec.ts @@ -0,0 +1,468 @@ +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 {of, Subject} from 'rxjs'; +import {ChatApi} from '@core/api/chat.api'; +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 ` + @if (defaultOption(); as fallback) { + + } @for (group of modelGroups(); track group.name) { 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, 0) > 1); + 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(); - return pick === null ? null : optionValue(pick.connection, pick.model); + const pick = this.store.pick() ?? this.store.defaultPick(); + return pick === null ? DEFAULT_OPTION : optionValue(pick.connection, pick.model); }); /** @@ -205,16 +228,6 @@ export class ChatComposer { private readonly searches = new Subject(); constructor() { - // The catalog each time the drawer opens, so a connection that went away or came back is seen. Not - // when the composer is built: a page load is no reason to ask each connection for its models. The - // panel is deferred to the drawer's first open, so the composer is usually built already open — - // `wasOpen` starts false, and that first open asks once. Without a chat model nothing is asked (ISC-513). - let wasOpen = false; - effect(() => { - const open = this.store.present() === true && this.store.view() !== 'closed'; - if (open && !wasOpen) untracked(() => this.dispatch.modelsRequested()); - wasOpen = open; - }); // A question typed and not yet sent is lost by a redirect to the sign-in; it holds one. Not the // kept question put back here: it outlives the redirect and comes back after the sign-in. trackUnsavedWork(() => { @@ -385,6 +398,10 @@ export class ChatComposer { /** 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; 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 index 8da0ab44..9ec2deb0 100644 --- a/frontend/src/app/features/chat/chat-model-select.browser.spec.ts +++ b/frontend/src/app/features/chat/chat-model-select.browser.spec.ts @@ -6,8 +6,10 @@ 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'; @@ -181,6 +183,43 @@ describe('the chat model select (ISC-511)', () => { expect(note.textContent!.trim()).toBe('Second connection: Not reachable: connection refused'); }); + it('keeps a spent second connection disabled while the status is asked again after a turn', async () => { + await open(CATALOG, SPENT); + + await askQuestion('What came in today?'); + server.next({event: 'turn', data: {turnId: 33, model: 'model-a', connection: 'PRIMARY'}}); + server.next({event: 'done', data: {state: 'DONE'}}); + await settle(); + + // The turn may have spent the second connection's day, so the status is asked again; until it + // answers, the last one known still holds. + const refresh = http.expectOne('/api/v1/chat/status'); + const [, second] = groups(); + expect(second.label).toBe('Second connection · No calls left today'); + expect(options(second).every((o) => o.disabled), 'still disabled while the status is asked again').toBe(true); + + refresh.flush({...SPENT, secondCalls: 3}); + await settle(); + expect(options(groups()[1]).every((o) => !o.disabled), 'enabled once the new status says so').toBe(true); + }); + + it('keeps a spent second connection disabled while the status popover asks again', async () => { + await open(CATALOG, SPENT); + + // Opening the empty chat's status popover asks the status again; the one known so far holds. + document.querySelector('.lg-chat-plate .lg-chat-status-trigger')!.dispatchEvent(new FocusEvent('focus')); + await settle(); + const reload = http.expectOne('/api/v1/chat/status'); + const [, second] = groups(); + expect(second.label).toBe('Second connection · No calls left today'); + expect(options(second).every((o) => o.disabled), 'still disabled while the popover asks again').toBe(true); + expect(document.querySelector('.lg-chat-status-second-calls')?.textContent?.trim(), 'the popover shows the status it has').toBe('20 of 20'); + + reload.flush(SPENT); + await settle(); + expect(options(groups()[1]).every((o) => o.disabled)).toBe(true); + }); + it('draws no select when the catalog offers one model in all', async () => { await open({connections: [{...CATALOG.connections[0], models: [{name: 'model-a', defaultModel: true}]}]}, null); @@ -230,9 +269,9 @@ function conversation(id: number, turns: readonly TurnView[]): ConversationView return {id, title: `Conversation ${id}`, pinnedOfferId: null, turns, updatedAt: '2026-10-12T08:00:00Z'}; } -/** What the stubbed API answers: the catalog, the status, and each stored conversation by id. */ +/** What the stubbed API answers: the catalog — null fails it — the status, and each stored conversation by id. */ interface StubServer { - readonly catalog: ChatModels; + readonly catalog: ChatModels | null; readonly status: ChatStatus | null; readonly conversations: Readonly>; } @@ -270,6 +309,9 @@ class Tab { document.body.appendChild(this.fixture.nativeElement); } + /** How often the chat asked for the model catalog. */ + catalogReads = 0; + static async open(url: string, server: StubServer): Promise { await page.viewport(1280, 800); const tab = new Tab(server); @@ -288,7 +330,11 @@ class Tab { /** Whatever the chat asked the stubbed server, answered; nothing else the app asks is. */ private answer(): void { this.http.match('/api/v1/chat/capability').forEach((r) => r.flush({present: true})); - this.http.match('/api/v1/chat/models').forEach((r) => r.flush(this.server.catalog)); + this.http.match('/api/v1/chat/models').forEach((r) => { + this.catalogReads++; + if (this.server.catalog === null) r.flush('down', {status: 503, statusText: 'Service Unavailable'}); + else r.flush(this.server.catalog); + }); this.http.match('/api/v1/chat/status').forEach((r) => r.flush(this.server.status)); this.http .match((r) => /^\/api\/v1\/chat\/conversations\/\d+$/.test(r.url)) @@ -300,6 +346,12 @@ class Tab { await this.settle(); } + /** The catalog asked again, as the App root does each time the drawer opens. */ + async reloadCatalog(): Promise { + TestBed.runInInjectionContext(() => injectDispatch(chatEvents)).modelsRequested(); + await this.settle(); + } + select(): HTMLSelectElement { return document.querySelector('select.lg-chat-model')!; } @@ -436,6 +488,187 @@ describe('the pick a conversation opens on (ISC-512)', () => { await tab.askQuestion('Anything new?'); expect(tab.ask.mock.calls.at(-1)).toEqual([13, 'Anything new?']); }); + + it('falls back to the default when the catalog reloads without the picked model', async () => { + const server = { + catalog: { + connections: [ + BILLED_SECOND.connections[0], + { + ...BILLED_SECOND.connections[1], + models: [ + {name: 'model-b', defaultModel: false}, + {name: 'model-c', defaultModel: false}, + ], + }, + ], + } as ChatModels, + status: CALLS_LEFT, + conversations: {10: conversation(10, [])}, + }; + tab = await Tab.open('/dashboard?chat=10', server); + await tab.pick('SECOND:model-b'); + expect(tab.select().value).toBe('SECOND:model-b'); + + // The catalog asked again no longer lists the pick. + server.catalog = BILLED_SECOND; + await tab.reloadCatalog(); + expect(tab.select().value, 'a pick the catalog no longer lists shows the default').toBe('PRIMARY:model-a'); + await tab.askQuestion('Anything new?'); + expect(tab.ask.mock.calls.at(-1), 'and the next question sends no pick').toEqual([10, 'Anything new?']); + + // A connection that became unreachable takes its pick with it the same way. + await tab.answerWith(50, 'model-a', 'PRIMARY'); + await tab.pick('SECOND:model-c'); + expect(tab.select().value).toBe('SECOND:model-c'); + server.catalog = {connections: [BILLED_SECOND.connections[0], {...BILLED_SECOND.connections[1], reachable: false, reason: 'timed out'}]}; + await tab.reloadCatalog(); + expect(tab.select().value, 'a pick on an unreachable connection shows the default').toBe('PRIMARY:model-a'); + await tab.askQuestion('And in Berlin?'); + expect(tab.ask.mock.calls.at(-1)).toEqual([10, 'And in Berlin?']); + }); + + it('falls back to the default when the status says the picked connection is spent, and brings the pick back the next day', async () => { + const server = {catalog: BILLED_SECOND, status: CALLS_LEFT as ChatStatus, conversations: {10: conversation(10, [])}}; + tab = await Tab.open('/dashboard?chat=10', server); + await tab.pick('SECOND:model-c'); + expect(tab.select().value).toBe('SECOND:model-c'); + + // The turn on the second connection spends its day; the status asked after it says so. + await tab.askQuestion('Which offers want Kafka?'); + expect(tab.ask.mock.calls.at(-1)).toEqual([10, 'Which offers want Kafka?', {connection: 'SECOND', model: 'model-c'}]); + server.status = {...CALLS_LEFT, secondCalls: 20}; + await tab.answerWith(51, 'model-c', 'SECOND'); + + expect(tab.select().value, 'a pick on a spent connection shows the default').toBe('PRIMARY:model-a'); + await tab.askQuestion('And in Berlin?'); + expect(tab.ask.mock.calls.at(-1), 'and the next question sends no pick').toEqual([10, 'And in Berlin?']); + await tab.answerWith(52, 'model-a', 'PRIMARY'); + + // The day turns: the pick itself was kept, so it comes back. + server.status = CALLS_LEFT; + await tab.reloadCatalog(); + expect(tab.select().value, 'the kept pick is back once calls are left').toBe('SECOND:model-c'); + }); + + it('keeps the explicit pick when the catalog asked again fails, and leaves judging it to the server', async () => { + const server = {catalog: BILLED_SECOND as ChatModels | null, status: CALLS_LEFT, conversations: {10: conversation(10, [])}}; + tab = await Tab.open('/dashboard?chat=10', server); + await tab.pick('SECOND:model-c'); + expect(tab.select().value).toBe('SECOND:model-c'); + + // The catalog asked again fails: nothing is left to judge the pick by, so it is not dropped. + server.catalog = null; + await tab.reloadCatalog(); + await tab.askQuestion('Anything new?'); + expect(tab.ask.mock.calls.at(-1), 'the pick is still sent').toEqual([10, 'Anything new?', {connection: 'SECOND', model: 'model-c'}]); + }); +}); + +/** + * The configured chat model is not among the listed ones (ISC-511, ISC-512): "the default" is then an + * option of its own, first, which sends no pick, and every listed model — the first included — is an + * explicit pick that is sent. + */ +describe('the select when no listed model is the default', () => { + let tab: Tab | null = null; + + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + afterEach(() => tab?.close()); + + const UNFLAGGED: ChatModels = { + connections: [ + { + ...BILLED_SECOND.connections[0], + models: [ + {name: 'model-a', defaultModel: false}, + {name: 'model-b', defaultModel: false}, + ], + }, + BILLED_SECOND.connections[1], + ], + defaultModelName: 'model-x', + }; + const SERVER = {catalog: UNFLAGGED, status: CALLS_LEFT, conversations: {10: conversation(10, [])}}; + + it('shows the default first and selected, and asks a new question without a pick', async () => { + tab = await Tab.open('/dashboard?chat=10', SERVER); + + const first = tab.select().options[0]; + expect(first.parentElement, 'the default stands outside the connection groups').toBe(tab.select()); + expect(first.textContent!.trim()).toBe('Default · model-x'); + expect(tab.select().selectedOptions[0], 'a conversation without a turn on the default').toBe(first); + + await tab.askQuestion('What came in today?'); + expect(tab.ask.mock.calls.at(-1), 'the default is asked without a pick').toEqual([10, 'What came in today?']); + }); + + it("sends the primary connection's first listed model as the explicit pick it is", async () => { + tab = await Tab.open('/dashboard?chat=10', SERVER); + + await tab.pick('PRIMARY:model-a'); + expect(tab.select().value).toBe('PRIMARY:model-a'); + await tab.askQuestion('What came in today?'); + expect(tab.ask).toHaveBeenLastCalledWith(10, 'What came in today?', {connection: 'PRIMARY', model: 'model-a'}); + + // And the default picked back sends none again. + await tab.answerWith(49, 'model-a', 'PRIMARY'); + await tab.pick(tab.select().options[0].value); + await tab.askQuestion('And in Berlin?'); + expect(tab.ask.mock.calls.at(-1), 'the default picked back asks without a pick').toEqual([10, 'And in Berlin?']); + }); + + it('disables the default with the reason when the primary connection it runs on is unreachable', async () => { + const [primary, second] = UNFLAGGED.connections; + tab = await Tab.open('/dashboard?chat=10', { + ...SERVER, + catalog: {...UNFLAGGED, connections: [{...primary, reachable: false, reason: 'connection refused', models: []}, second]}, + }); + + const first = tab.select().options[0]; + expect(first.parentElement, 'the default stands outside the connection groups').toBe(tab.select()); + expect(first.textContent!.trim()).toBe('Default · model-x'); + expect(first.disabled, 'the default runs on the unreachable primary connection').toBe(true); + const note = document.getElementById(tab.select().getAttribute('aria-describedby')!)!; + expect(note.textContent, 'the reason in words, the select described by it').toContain('Primary connection: Not reachable: connection refused'); + const other = Array.from(tab.select().options).find((o) => o.value === 'SECOND:model-c')!; + expect(other.disabled, 'the reachable connection can still be picked').toBe(false); + // Never moved onto another connection by itself — that one may be billed; the person picks it. + expect(tab.select().value).toBe('default'); + }); + + it('opens a new conversation on the default, named without a model when the server names none', async () => { + tab = await Tab.open('/dashboard?chat=new', {...SERVER, catalog: {...UNFLAGGED, defaultModelName: null}}); + + const first = tab.select().options[0]; + expect(first.textContent!.trim()).toBe('Default'); + expect(tab.select().selectedOptions[0]).toBe(first); + await tab.askQuestion('What came in today?'); + expect(tab.ask.mock.calls.at(-1)).toEqual([77, 'What came in today?']); + }); +}); + +/** A turn stored before the select has no connection; it was answered on the primary one (ISC-511). */ +describe('the billed marker under a turn stored before the select', () => { + let tab: Tab | null = null; + + afterEach(() => tab?.close()); + + it('reads a stored turn without a connection as answered on the billed primary connection', async () => { + tab = await Tab.open('/dashboard?chat=11', { + catalog: CATALOG, + status: {...SPENT, secondCalls: 0}, + conversations: {11: conversation(11, [storedTurn(45, 'model-old', null)])}, + }); + + const line = document.querySelector('.lg-chat-answer-model')!; + expect(line.querySelector('.lg-chat-answer-model-name')!.textContent!.trim()).toBe('model-old'); + expect(line.querySelector('.lg-chat-billed'), 'the primary connection is billed').not.toBeNull(); + }); }); /** Anti (ISC-513): one connection is one group, and an unbilled primary draws no marker anywhere. */ @@ -466,3 +699,28 @@ describe('the select with the primary connection alone (ISC-513)', () => { expect(document.body.textContent).not.toContain('billed'); }); }); + +/** The catalog is asked each time the drawer opens, not only the first time (ISC-511). */ +describe('the catalog on reopening the drawer', () => { + let tab: Tab | null = null; + + afterEach(() => tab?.close()); + + it('asks for the catalog again when the drawer is closed and opened again', async () => { + tab = await Tab.open('/dashboard?chat=10', { + catalog: CATALOG, + status: CALLS_LEFT, + conversations: {10: conversation(10, [])}, + }); + expect(tab.catalogReads, 'the first open').toBe(1); + + // Closed the way the drawer's ✕ closes it: a navigation without `chat` alone is put back (ISC-444). + TestBed.runInInjectionContext(() => injectDispatch(chatEvents)).closeRequested(); + await tab.settle(); + expect(tab.select(), 'the drawer is closed').toBeNull(); + expect(tab.catalogReads, 'closed, nothing asked').toBe(1); + + await tab.go('/dashboard?chat=10'); + expect(tab.catalogReads, 'reopened').toBe(2); + }); +}); diff --git a/frontend/src/app/features/chat/chat-panel/chat-panel.browser.spec.ts b/frontend/src/app/features/chat/chat-panel/chat-panel.browser.spec.ts index 10093305..d3afd2e6 100644 --- a/frontend/src/app/features/chat/chat-panel/chat-panel.browser.spec.ts +++ b/frontend/src/app/features/chat/chat-panel/chat-panel.browser.spec.ts @@ -415,4 +415,43 @@ describe('the status popovers (ISC-474, ISC-475, ISC-476)', () => { expect(second.textContent!.trim()).toBe('3 of 20'); expect(second.previousElementSibling!.textContent!.trim()).toBe('Calls on the second connection today'); }); + + it('keeps the numbers it has when a refresh fails, with a note beside them, and says failed only with none', async () => { + await TestBed.inject(Router).navigateByUrl('/dashboard?chat=new'); + await settle(); + http.match((r) => r.url === '/api/v1/chat/capability').forEach((r) => r.flush({present: true})); + await settle(); + http.match(() => true).forEach((r) => r.flush([])); + await settle(); + const plate = () => document.querySelector('.lg-chat-plate .lg-chat-status-trigger')!; + + // The first read fails: no status at all, so the bare failed state. + await hover(plate()); + http.expectOne('/api/v1/chat/status').flush('down', {status: 503, statusText: 'Service Unavailable'}); + await settle(); + expect(openPanel()!.textContent).toContain('The status did not load.'); + expect(openPanel()!.querySelector('.lg-chat-status-calls')).toBeNull(); + await leave(plate()); + + // The next read answers: the numbers, no note. + await hover(plate()); + http.expectOne('/api/v1/chat/status') + .flush({model: 'chat-model', callsUsed: 12, callsLimit: 200, toolRounds: 6, secondCalls: 3, secondLimit: 20}); + await settle(); + expect(openPanel()!.querySelector('.lg-chat-status-calls')!.textContent!.trim()).toBe('12 of 200'); + expect(openPanel()!.querySelector('.lg-chat-status-stale')).toBeNull(); + await leave(plate()); + + // A refresh fails over a kept status: the numbers stay, the note says the refresh failed. + await hover(plate()); + http.expectOne('/api/v1/chat/status').flush('down', {status: 503, statusText: 'Service Unavailable'}); + await settle(); + const panel = openPanel()!; + expect(panel.querySelector('.lg-chat-status-calls')!.textContent!.trim(), 'the kept numbers').toBe('12 of 200'); + expect(panel.querySelector('.lg-chat-status-second-calls')!.textContent!.trim()).toBe('3 of 20'); + expect(panel.querySelector('.lg-chat-status-stale')?.textContent?.trim(), 'the note beside them').toBe( + 'The last refresh failed; these numbers may be out of date.', + ); + expect(panel.textContent).not.toContain('The status did not load.'); + }); }); diff --git a/frontend/src/app/features/chat/chat-panel/chat-panel.html b/frontend/src/app/features/chat/chat-panel/chat-panel.html index 62da1af7..cb3e064f 100644 --- a/frontend/src/app/features/chat/chat-panel/chat-panel.html +++ b/frontend/src/app/features/chat/chat-panel/chat-panel.html @@ -206,7 +206,7 @@

{{ 'chat.empty.title' | transloco [last]="last" [conversationId]="store.conversation()?.id ?? null" [model]="turn.model ?? null" - [billed]="store.billedConnections().includes(turn.connection)" + [billed]="billed(turn)" /> diff --git a/frontend/src/app/features/chat/chat-panel/chat-panel.ts b/frontend/src/app/features/chat/chat-panel/chat-panel.ts index 28c8fbc4..fb739ac5 100644 --- a/frontend/src/app/features/chat/chat-panel/chat-panel.ts +++ b/frontend/src/app/features/chat/chat-panel/chat-panel.ts @@ -20,7 +20,7 @@ import {TranslocoPipe, TranslocoService} from '@jsverse/transloco'; import {injectDispatch} from '@ngrx/signals/events'; import {filter} from 'rxjs'; import {ChatApi} from '@core/api/chat.api'; -import {ChatSuggestion, conversationTitle, formatChatCtx, SuggestionsFor, TITLE_MAX, TurnView} from '@core/model/chat'; +import {answeredOn, ChatConnectionName, ChatSuggestion, conversationTitle, formatChatCtx, SuggestionsFor, TITLE_MAX, TurnView} from '@core/model/chat'; import {chatEvents} from '@core/store/chat.events'; import {ChatStore} from '@core/store/chat.store'; import {Icon} from '@shared/icon/icon'; @@ -302,6 +302,11 @@ export class ChatPanel { return turnFrame(turn.state, turn.steps); } + /** Whether an answer ran on a billed connection (ISC-511): a stored turn without one ran on the primary. */ + protected billed(turn: {readonly connection?: ChatConnectionName | null}): boolean { + return this.store.billedConnections().includes(answeredOn(turn)); + } + /** What the ring beside an answer says on hover or focus (ISC-474), for a stored and a live turn alike. */ protected statusOf(turn: TurnLike): TurnStatus { return cachedTurnStatus(turn); diff --git a/frontend/src/app/features/chat/chat-status-popover/chat-status-popover.html b/frontend/src/app/features/chat/chat-status-popover/chat-status-popover.html index b36c7ed1..c899ff08 100644 --- a/frontend/src/app/features/chat/chat-status-popover/chat-status-popover.html +++ b/frontend/src/app/features/chat/chat-status-popover/chat-status-popover.html @@ -45,6 +45,8 @@ } @else {

{{ 'chat.status.chatTitle' | transloco }}

+ @if (chat(); as c) {
{{ 'chat.status.model' | transloco }}
@@ -58,9 +60,12 @@
{{ 'chat.status.rounds' | transloco }}
{{ c.toolRounds }}
+ @if (chatFailed()) { +

{{ 'chat.status.refreshFailed' | transloco }}

+ } } @else if (chatFailed()) {

{{ 'chat.status.chatFailed' | transloco }}

- } @else { + } @else if (chatLoading()) {

{{ 'chat.status.chatLoading' | transloco }}

} } diff --git a/frontend/src/app/features/chat/chat-status-popover/chat-status-popover.ts b/frontend/src/app/features/chat/chat-status-popover/chat-status-popover.ts index 66d96ad4..5c54fb83 100644 --- a/frontend/src/app/features/chat/chat-status-popover/chat-status-popover.ts +++ b/frontend/src/app/features/chat/chat-status-popover/chat-status-popover.ts @@ -46,9 +46,10 @@ export class ChatStatusPopover { protected readonly panelId = `lg-chat-status-${++instances}`; protected readonly open = signal(false); - /** The chat's status, asked through the store on each open; null while it loads. */ + /** The chat's status, asked through the store on each open; null until the first read answers. */ protected readonly chat = this.store.status; protected readonly chatFailed = this.store.statusFailed; + protected readonly chatLoading = this.store.statusLoading; /** * The second connection's calls of its own ceiling (ISC-510), or null without a second connection. * A ceiling of 0 is still a ceiling and still shown, so the test is for absence, not falsiness. From f934cab4abb63e64f7031dab3c6318e6d611e076 Mon Sep 17 00:00:00 2001 From: Marcello Muscara Date: Mon, 12 Oct 2026 16:03:28 +0200 Subject: [PATCH 4/4] Bump version to 0.5.5 The chat's model select with an optional second connection (spec 025), the chat panel loaded on its first open, and the touch icon for every apple-touch-icon variant. Same seven files as the 0.5.4 bump; StatusControllerTest and app.spec pass. --- CHANGELOG.md | 22 ++++++++++++++++++- backend/build.gradle.kts | 2 +- .../leadgen/config/ConfigProperties.java | 2 +- backend/src/main/resources/application.yaml | 6 ++--- .../leadgen/web/StatusControllerTest.java | 4 ++-- frontend/package.json | 2 +- frontend/src/app/app.spec.ts | 4 ++-- 7 files changed, 31 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13a7c1b7..8e1eb953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ 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 @@ -21,6 +28,18 @@ may change in any release. See the status note in the README. 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 @@ -1714,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 38d5aa72..cf8c3450 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/config/ConfigProperties.java b/backend/src/main/java/de/codeministry/leadgen/config/ConfigProperties.java index 6d41c064..a6123772 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/resources/application.yaml b/backend/src/main/resources/application.yaml index 76bf640d..0ea01d37 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/test/java/de/codeministry/leadgen/web/StatusControllerTest.java b/backend/src/test/java/de/codeministry/leadgen/web/StatusControllerTest.java index 955904ee..6f7caf38 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/frontend/package.json b/frontend/package.json index 1752cc34..7c134cdf 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/src/app/app.spec.ts b/frontend/src/app/app.spec.ts index b608ceb7..6cc73496 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(); });