1 line
488 KiB
JSON
1 line
488 KiB
JSON
{"repo": "bchapuis/brokk", "files": {"src/main/java/io/github/jbellis/brokk/Models.java": {"6c897c986b749b831dcc3a9a1350dd2c85dcb1c8": {"t": 1785745670.3695407, "n": 27465, "h": "45700345a740fda3", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.data.message.SystemMessage;\nimport dev.langchain4j.data.message.UserMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.openai.OpenAiTokenizer;\nimport dev.langchain4j.model.output.Response;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport okhttp3.MediaType;\nimport okhttp3.MultipartBody;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport org.apache.logging.log4j.LogManager;\nimport org.apache.logging.log4j.Logger;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.net.http.HttpResponse;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.TimeUnit;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n ChatLanguageModel searchModel,\n String editModelName,\n String applyModelName,\n String quickModelName,\n String searchModelName,\n SpeechToTextModel sttModel,\n String sttModelName)\n{\n public static String[] defaultKeyNames = { \"ANTHROPIC_API_KEY\", \"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\" };\n\n /**\n * Returns the name of the given model.\n * \n * @param model The model to get the name for\n * @return The name of the model, or \"unknown\" if the model is not recognized\n */\n public String nameOf(Object model) {\n if (model == editModel) return editModelName;\n if (model == applyModel) return applyModelName;\n if (model == quickModel) return quickModelName;\n if (model == searchModel) return searchModelName;\n return \"unknown\";\n }\n // langchain4j only supports openai tokenization, this is not very accurate for other providers\n // but doing loc-based estimation based on information in the responses was worse\n private static final OpenAiTokenizer tokenizer = new OpenAiTokenizer(\"o3-mini\");\n\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n \n search_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n maxTokens: 8192\n\n search_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n enableCaching: true\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \n \"\"\";\n\n private static final String DEEPSEEK_DEFAULTS = \"\"\"\n edit_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n search_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n apply_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n quick_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>~/.config/brokk/keys.properties</code>, or by <code>System.getenv</code>.\n * </li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = getKey(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = getKey(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n String deepseekKey = getKey(\"DEEPSEEK_API_KEY\");\n if (deepseekKey != null && !deepseekKey.isBlank()) {\n return buildModelsFromYaml(DEEPSEEK_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static String getKey(String keyName) {\n // First try properties file\n try {\n Path keysPath = Project.getLlmKeysPath();\n if (Files.exists(keysPath)) {\n var properties = new java.util.Properties();\n try (var reader = Files.newBufferedReader(keysPath)) {\n properties.load(reader);\n }\n String propValue = properties.getProperty(keyName);\n if (propValue != null && !propValue.isBlank()) {\n return propValue.trim();\n }\n }\n } catch (IOException e) {\n System.err.println(\"Error reading LLM keys: \" + e.getMessage());\n }\n\n // Next try system environment variables\n String envValue = System.getenv(keyName);\n if (envValue != null && !envValue.isBlank()) {\n return envValue.trim();\n }\n\n // Nothing found\n return null;\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n var searchModel = buildChatModel(top, \"search_model\", false);\n\n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n String searchModelName = readModelName(top, \"search_model\");\n\n // Check if OpenAI key is available for STT\n String openaiKey = getKey(\"OPENAI_API_KEY\");\n SpeechToTextModel stt;\n String sttModelName;\n if (openaiKey != null && !openaiKey.isBlank()) {\n stt = new OpenAiWhisperSTT(openaiKey);\n sttModelName = \"gpt-4o-transcribe\";\n } else {\n stt = new UnavailableSTT();\n sttModelName = \"disabled\";\n }\n\n return new Models(editModel, applyModel, quickModel, searchModel,\n editModelName, applyModelName, quickModelName, searchModelName,\n stt, sttModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\",\n \"disabled\",\n new UnavailableSTT(),\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is a property name\n String resolvedKey = getKey(apiKeyOrEnv) == null ? apiKeyOrEnv : getKey(apiKeyOrEnv);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n \n // Enable caching for search model if specified\n if (modelMap.containsKey(\"enableCaching\") && (boolean)modelMap.get(\"enableCaching\")) {\n builder = builder\n .cacheSystemMessages(true);\n // don't cache tools b/c each one is considered a separate \"block\" and anthropic only allows 4 cached blocks\n // .cacheTools(true);\n }\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static String getText(ChatMessage message) {\n return switch (message) {\n case SystemMessage sm -> sm.text();\n case AiMessage am -> am.text();\n case UserMessage um -> um.singleText();\n default -> throw new UnsupportedOperationException(message.getClass().toString());\n };\n }\n\n public static int getApproximateTokens(String text) {\n return tokenizer.encode(text).size();\n }\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }\n\n /**\n * Simple interface for speech-to-text operations.\n */\n public interface SpeechToTextModel\n {\n /**\n * Transcribes the given audio file to text, returning the transcript.\n */\n String transcribe(Path audioFile) throws IOException;\n }\n\n /**\n * Stubbed / unavailable STT model that always returns a message\n * indicating that STT is disabled.\n */\n public static class UnavailableSTT implements SpeechToTextModel\n {\n @Override\n public String transcribe(Path audioFile) {\n return \"Speech-to-text is unavailable (no OpenAI key).\";\n }\n }\n\n /**\n * Real STT using OpenAI Whisper v1. Uses OkHttp for multipart/form-data upload.\n * We block until the transcription is done. This requires an OpenAI API key.\n */\n public static class OpenAiWhisperSTT implements SpeechToTextModel\n {\n private static final Logger logger = LogManager.getLogger(OpenAiWhisperSTT.class);\n\n private final String apiKey;\n private static final String WHISPER_ENDPOINT = \"https://api.openai.com/v1/audio/transcriptions\";\n private final OkHttpClient httpClient;\n\n public OpenAiWhisperSTT(String apiKey)\n {\n this.apiKey = apiKey;\n this.httpClient = new OkHttpClient.Builder()\n .connectTimeout(10, TimeUnit.SECONDS)\n .readTimeout(0, TimeUnit.SECONDS)\n .writeTimeout(0, TimeUnit.SECONDS)\n .build();\n }\n\n /**\n * Determines the MediaType based on file extension.\n * @param fileName Name of the file\n * @return MediaType for the HTTP request\n */\n private MediaType getMediaTypeFromFileName(String fileName)\n {\n var extension = fileName.toLowerCase();\n int dotIndex = extension.lastIndexOf('.');\n if (dotIndex > 0)\n {\n extension = extension.substring(dotIndex + 1);\n }\n\n return switch (extension)\n {\n case \"mp3\", \"mpga\", \"mpeg\" -> MediaType.parse(\"audio/mpeg\");\n case \"mp4\", \"m4a\" -> MediaType.parse(\"audio/mp4\");\n case \"wav\" -> MediaType.parse(\"audio/wav\");\n case \"webm\" -> MediaType.parse(\"audio/webm\");\n default -> MediaType.parse(\"audio/mpeg\"); // fallback\n };\n }\n\n /**\n * Transcribes the given audio file to text, returning the transcript.\n * Uses OkHttp for multipart/form-data with file, model, and response_format fields.\n */\n @Override\n public String transcribe(Path audioFile) throws IOException\n {\n logger.info(\"Beginning transcription for file: {}\", audioFile);\n File file = audioFile.toFile();\n\n // Use getMediaTypeFromFileName if you want to differentiate .wav vs .mp3, etc.\n // For a simpler approach, you could always use MediaType.get(\"audio/mpeg\") or \"audio/wav\"\n MediaType mediaType = getMediaTypeFromFileName(file.getName());\n RequestBody fileBody = RequestBody.create(file, mediaType);\n\n RequestBody requestBody = new MultipartBody.Builder()\n .setType(MultipartBody.FORM)\n .addFormDataPart(\"file\", file.getName(), fileBody)\n .addFormDataPart(\"model\", \"whisper-1\")\n .addFormDataPart(\"language\", \"en\")\n .addFormDataPart(\"response_format\", \"json\")\n .build();\n\n Request request = new Request.Builder()\n .url(WHISPER_ENDPOINT)\n .header(\"Authorization\", \"Bearer \" + apiKey)\n .addHeader(\"Content-Type\", \"multipart/form-data\")\n .post(requestBody)\n .build();\n\n logger.debug(\"Sending Whisper STT request to {}\", WHISPER_ENDPOINT);\n\n try (okhttp3.Response response = httpClient.newCall(request).execute()) {\n String bodyStr = response.body() != null ? response.body().string() : \"\";\n\n logger.debug(\"Received response, status = {}\", response.code());\n\n if (!response.isSuccessful()) {\n logger.error(\"Whisper STT call failed with status {}: {}\", response.code(), bodyStr);\n throw new IOException(\"Whisper STT call failed with status \"\n + response.code() + \": \" + bodyStr);\n }\n\n // Parse JSON response\n try {\n ObjectMapper mapper = new ObjectMapper();\n JsonNode node = mapper.readTree(bodyStr);\n if (node.has(\"text\")) {\n String transcript = node.get(\"text\").asText().trim();\n logger.info(\"Transcription successful, text length={} chars\", transcript.length());\n return transcript;\n } else {\n logger.warn(\"No 'text' field found in Whisper response.\");\n return \"No transcription found in response.\";\n }\n } catch (Exception e) {\n logger.error(\"Error parsing Whisper JSON response: {}\", e.getMessage());\n throw new IOException(\"Error parsing Whisper JSON response: \" + e.getMessage(), e);\n }\n }\n }\n }\n}\n"}, "f9586cd8fcdcaf833c4349b2e3624876ddbe4697": {"t": 1785755614.8818214, "n": 27466, "h": "994047b430f71b28", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.data.message.SystemMessage;\nimport dev.langchain4j.data.message.UserMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.openai.OpenAiTokenizer;\nimport dev.langchain4j.model.output.Response;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport okhttp3.MediaType;\nimport okhttp3.MultipartBody;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport org.apache.logging.log4j.LogManager;\nimport org.apache.logging.log4j.Logger;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.net.http.HttpResponse;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.TimeUnit;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n ChatLanguageModel searchModel,\n String editModelName,\n String applyModelName,\n String quickModelName,\n String searchModelName,\n SpeechToTextModel sttModel,\n String sttModelName)\n{\n public static String[] defaultKeyNames = { \"ANTHROPIC_API_KEY\", \"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\" };\n\n /**\n * Returns the name of the given model.\n * \n * @param model The model to get the name for\n * @return The name of the model, or \"unknown\" if the model is not recognized\n */\n public String nameOf(Object model) {\n if (model == editModel) return editModelName;\n if (model == applyModel) return applyModelName;\n if (model == quickModel) return quickModelName;\n if (model == searchModel) return searchModelName;\n return \"unknown\";\n }\n // langchain4j only supports openai tokenization, this is not very accurate for other providers\n // but doing loc-based estimation based on information in the responses was worse\n private static final OpenAiTokenizer tokenizer = new OpenAiTokenizer(\"o3-mini\");\n\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n search_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n maxTokens: 8192\n\n search_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n enableCaching: true\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \n \"\"\";\n\n private static final String DEEPSEEK_DEFAULTS = \"\"\"\n edit_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n search_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n apply_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n quick_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>~/.config/brokk/keys.properties</code>, or by <code>System.getenv</code>.\n * </li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = getKey(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = getKey(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n String deepseekKey = getKey(\"DEEPSEEK_API_KEY\");\n if (deepseekKey != null && !deepseekKey.isBlank()) {\n return buildModelsFromYaml(DEEPSEEK_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static String getKey(String keyName) {\n // First try properties file\n try {\n Path keysPath = Project.getLlmKeysPath();\n if (Files.exists(keysPath)) {\n var properties = new java.util.Properties();\n try (var reader = Files.newBufferedReader(keysPath)) {\n properties.load(reader);\n }\n String propValue = properties.getProperty(keyName);\n if (propValue != null && !propValue.isBlank()) {\n return propValue.trim();\n }\n }\n } catch (IOException e) {\n System.err.println(\"Error reading LLM keys: \" + e.getMessage());\n }\n\n // Next try system environment variables\n String envValue = System.getenv(keyName);\n if (envValue != null && !envValue.isBlank()) {\n return envValue.trim();\n }\n\n // Nothing found\n return null;\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n var searchModel = buildChatModel(top, \"search_model\", false);\n\n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n String searchModelName = readModelName(top, \"search_model\");\n\n // Check if OpenAI key is available for STT\n String openaiKey = getKey(\"OPENAI_API_KEY\");\n SpeechToTextModel stt;\n String sttModelName;\n if (openaiKey != null && !openaiKey.isBlank()) {\n stt = new OpenAiWhisperSTT(openaiKey);\n sttModelName = \"gpt-4o-transcribe\";\n } else {\n stt = new UnavailableSTT();\n sttModelName = \"disabled\";\n }\n\n return new Models(editModel, applyModel, quickModel, searchModel,\n editModelName, applyModelName, quickModelName, searchModelName,\n stt, sttModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\",\n \"disabled\",\n new UnavailableSTT(),\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is a property name\n String resolvedKey = getKey(apiKeyOrEnv) == null ? apiKeyOrEnv : getKey(apiKeyOrEnv);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n \n // Enable caching for search model if specified\n if (modelMap.containsKey(\"enableCaching\") && (boolean)modelMap.get(\"enableCaching\")) {\n builder = builder\n .cacheSystemMessages(true);\n // don't cache tools b/c each one is considered a separate \"block\" and anthropic only allows 4 cached blocks\n // .cacheTools(true);\n }\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static String getText(ChatMessage message) {\n return switch (message) {\n case SystemMessage sm -> sm.text();\n case AiMessage am -> am.text();\n case UserMessage um -> um.singleText();\n default -> throw new UnsupportedOperationException(message.getClass().toString());\n };\n }\n\n public static int getApproximateTokens(String text) {\n return tokenizer.encode(text).size();\n }\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }\n\n /**\n * Simple interface for speech-to-text operations.\n */\n public interface SpeechToTextModel\n {\n /**\n * Transcribes the given audio file to text, returning the transcript.\n */\n String transcribe(Path audioFile) throws IOException;\n }\n\n /**\n * Stubbed / unavailable STT model that always returns a message\n * indicating that STT is disabled.\n */\n public static class UnavailableSTT implements SpeechToTextModel\n {\n @Override\n public String transcribe(Path audioFile) {\n return \"Speech-to-text is unavailable (no OpenAI key).\";\n }\n }\n\n /**\n * Real STT using OpenAI Whisper v1. Uses OkHttp for multipart/form-data upload.\n * We block until the transcription is done. This requires an OpenAI API key.\n */\n public static class OpenAiWhisperSTT implements SpeechToTextModel\n {\n private static final Logger logger = LogManager.getLogger(OpenAiWhisperSTT.class);\n\n private final String apiKey;\n private static final String WHISPER_ENDPOINT = \"https://api.openai.com/v1/audio/transcriptions\";\n private final OkHttpClient httpClient;\n\n public OpenAiWhisperSTT(String apiKey)\n {\n this.apiKey = apiKey;\n this.httpClient = new OkHttpClient.Builder()\n .connectTimeout(10, TimeUnit.SECONDS)\n .readTimeout(0, TimeUnit.SECONDS)\n .writeTimeout(0, TimeUnit.SECONDS)\n .build();\n }\n\n /**\n * Determines the MediaType based on file extension.\n * @param fileName Name of the file\n * @return MediaType for the HTTP request\n */\n private MediaType getMediaTypeFromFileName(String fileName)\n {\n var extension = fileName.toLowerCase();\n int dotIndex = extension.lastIndexOf('.');\n if (dotIndex > 0)\n {\n extension = extension.substring(dotIndex + 1);\n }\n\n return switch (extension)\n {\n case \"mp3\", \"mpga\", \"mpeg\" -> MediaType.parse(\"audio/mpeg\");\n case \"mp4\", \"m4a\" -> MediaType.parse(\"audio/mp4\");\n case \"wav\" -> MediaType.parse(\"audio/wav\");\n case \"webm\" -> MediaType.parse(\"audio/webm\");\n default -> MediaType.parse(\"audio/mpeg\"); // fallback\n };\n }\n\n /**\n * Transcribes the given audio file to text, returning the transcript.\n * Uses OkHttp for multipart/form-data with file, model, and response_format fields.\n */\n @Override\n public String transcribe(Path audioFile) throws IOException\n {\n logger.info(\"Beginning transcription for file: {}\", audioFile);\n File file = audioFile.toFile();\n\n // Use getMediaTypeFromFileName if you want to differentiate .wav vs .mp3, etc.\n // For a simpler approach, you could always use MediaType.get(\"audio/mpeg\") or \"audio/wav\"\n MediaType mediaType = getMediaTypeFromFileName(file.getName());\n RequestBody fileBody = RequestBody.create(file, mediaType);\n\n RequestBody requestBody = new MultipartBody.Builder()\n .setType(MultipartBody.FORM)\n .addFormDataPart(\"file\", file.getName(), fileBody)\n .addFormDataPart(\"model\", \"whisper-1\")\n .addFormDataPart(\"language\", \"en\")\n .addFormDataPart(\"response_format\", \"json\")\n .build();\n\n Request request = new Request.Builder()\n .url(WHISPER_ENDPOINT)\n .header(\"Authorization\", \"Bearer \" + apiKey)\n .addHeader(\"Content-Type\", \"multipart/form-data\")\n .post(requestBody)\n .build();\n\n logger.debug(\"Sending Whisper STT request to {}\", WHISPER_ENDPOINT);\n\n try (okhttp3.Response response = httpClient.newCall(request).execute()) {\n String bodyStr = response.body() != null ? response.body().string() : \"\";\n\n logger.debug(\"Received response, status = {}\", response.code());\n\n if (!response.isSuccessful()) {\n logger.error(\"Whisper STT call failed with status {}: {}\", response.code(), bodyStr);\n throw new IOException(\"Whisper STT call failed with status \"\n + response.code() + \": \" + bodyStr);\n }\n\n // Parse JSON response\n try {\n ObjectMapper mapper = new ObjectMapper();\n JsonNode node = mapper.readTree(bodyStr);\n if (node.has(\"text\")) {\n String transcript = node.get(\"text\").asText().trim();\n logger.info(\"Transcription successful, text length={} chars\", transcript.length());\n return transcript;\n } else {\n logger.warn(\"No 'text' field found in Whisper response.\");\n return \"No transcription found in response.\";\n }\n } catch (Exception e) {\n logger.error(\"Error parsing Whisper JSON response: {}\", e.getMessage());\n throw new IOException(\"Error parsing Whisper JSON response: \" + e.getMessage(), e);\n }\n }\n }\n }\n}\n"}, "e137a8b964205e3876d92670a5187d63469538a4": {"t": 1785755616.619986, "n": 27458, "h": "4da20616c661f74e", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.data.message.SystemMessage;\nimport dev.langchain4j.data.message.UserMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.openai.OpenAiTokenizer;\nimport dev.langchain4j.model.output.Response;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport okhttp3.MediaType;\nimport okhttp3.MultipartBody;\nimport okhttp3.OkHttpClient;\nimport okhttp3.Request;\nimport okhttp3.RequestBody;\nimport org.apache.logging.log4j.LogManager;\nimport org.apache.logging.log4j.Logger;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.File;\nimport java.io.IOException;\nimport java.net.http.HttpResponse;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.TimeUnit;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n ChatLanguageModel searchModel,\n String editModelName,\n String applyModelName,\n String quickModelName,\n String searchModelName,\n SpeechToTextModel sttModel,\n String sttModelName)\n{\n public static String[] defaultKeyNames = { \"ANTHROPIC_API_KEY\", \"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\" };\n\n /**\n * Returns the name of the given model.\n * \n * @param model The model to get the name for\n * @return The name of the model, or \"unknown\" if the model is not recognized\n */\n public String nameOf(Object model) {\n if (model == editModel) return editModelName;\n if (model == applyModel) return applyModelName;\n if (model == quickModel) return quickModelName;\n if (model == searchModel) return searchModelName;\n return \"unknown\";\n }\n // langchain4j only supports openai tokenization, this is not very accurate for other providers\n // but doing loc-based estimation based on information in the responses was worse\n private static final OpenAiTokenizer tokenizer = new OpenAiTokenizer(\"o3-mini\");\n\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n search_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n maxTokens: 8192\n\n search_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n enableCaching: true\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \n \"\"\";\n\n private static final String DEEPSEEK_DEFAULTS = \"\"\"\n edit_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n search_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n apply_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n quick_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>~/.config/brokk/keys.properties</code>, or by <code>System.getenv</code>.\n * </li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = getKey(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = getKey(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n String deepseekKey = getKey(\"DEEPSEEK_API_KEY\");\n if (deepseekKey != null && !deepseekKey.isBlank()) {\n return buildModelsFromYaml(DEEPSEEK_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static String getKey(String keyName) {\n // First try properties file\n try {\n Path keysPath = Project.getLlmKeysPath();\n if (Files.exists(keysPath)) {\n var properties = new java.util.Properties();\n try (var reader = Files.newBufferedReader(keysPath)) {\n properties.load(reader);\n }\n String propValue = properties.getProperty(keyName);\n if (propValue != null && !propValue.isBlank()) {\n return propValue.trim();\n }\n }\n } catch (IOException e) {\n System.err.println(\"Error reading LLM keys: \" + e.getMessage());\n }\n\n // Next try system environment variables\n String envValue = System.getenv(keyName);\n if (envValue != null && !envValue.isBlank()) {\n return envValue.trim();\n }\n\n // Nothing found\n return null;\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n var searchModel = buildChatModel(top, \"search_model\", false);\n\n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n String searchModelName = readModelName(top, \"search_model\");\n\n // Check if OpenAI key is available for STT\n String openaiKey = getKey(\"OPENAI_API_KEY\");\n SpeechToTextModel stt;\n String sttModelName;\n if (openaiKey != null && !openaiKey.isBlank()) {\n stt = new OpenAiWhisperSTT(openaiKey);\n sttModelName = \"whisper-1\";\n } else {\n stt = new UnavailableSTT();\n sttModelName = \"disabled\";\n }\n\n return new Models(editModel, applyModel, quickModel, searchModel,\n editModelName, applyModelName, quickModelName, searchModelName,\n stt, sttModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\",\n \"disabled\",\n new UnavailableSTT(),\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is a property name\n String resolvedKey = getKey(apiKeyOrEnv) == null ? apiKeyOrEnv : getKey(apiKeyOrEnv);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n \n // Enable caching for search model if specified\n if (modelMap.containsKey(\"enableCaching\") && (boolean)modelMap.get(\"enableCaching\")) {\n builder = builder\n .cacheSystemMessages(true);\n // don't cache tools b/c each one is considered a separate \"block\" and anthropic only allows 4 cached blocks\n // .cacheTools(true);\n }\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static String getText(ChatMessage message) {\n return switch (message) {\n case SystemMessage sm -> sm.text();\n case AiMessage am -> am.text();\n case UserMessage um -> um.singleText();\n default -> throw new UnsupportedOperationException(message.getClass().toString());\n };\n }\n\n public static int getApproximateTokens(String text) {\n return tokenizer.encode(text).size();\n }\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }\n\n /**\n * Simple interface for speech-to-text operations.\n */\n public interface SpeechToTextModel\n {\n /**\n * Transcribes the given audio file to text, returning the transcript.\n */\n String transcribe(Path audioFile) throws IOException;\n }\n\n /**\n * Stubbed / unavailable STT model that always returns a message\n * indicating that STT is disabled.\n */\n public static class UnavailableSTT implements SpeechToTextModel\n {\n @Override\n public String transcribe(Path audioFile) {\n return \"Speech-to-text is unavailable (no OpenAI key).\";\n }\n }\n\n /**\n * Real STT using OpenAI Whisper v1. Uses OkHttp for multipart/form-data upload.\n * We block until the transcription is done. This requires an OpenAI API key.\n */\n public static class OpenAiWhisperSTT implements SpeechToTextModel\n {\n private static final Logger logger = LogManager.getLogger(OpenAiWhisperSTT.class);\n\n private final String apiKey;\n private static final String WHISPER_ENDPOINT = \"https://api.openai.com/v1/audio/transcriptions\";\n private final OkHttpClient httpClient;\n\n public OpenAiWhisperSTT(String apiKey)\n {\n this.apiKey = apiKey;\n this.httpClient = new OkHttpClient.Builder()\n .connectTimeout(10, TimeUnit.SECONDS)\n .readTimeout(0, TimeUnit.SECONDS)\n .writeTimeout(0, TimeUnit.SECONDS)\n .build();\n }\n\n /**\n * Determines the MediaType based on file extension.\n * @param fileName Name of the file\n * @return MediaType for the HTTP request\n */\n private MediaType getMediaTypeFromFileName(String fileName)\n {\n var extension = fileName.toLowerCase();\n int dotIndex = extension.lastIndexOf('.');\n if (dotIndex > 0)\n {\n extension = extension.substring(dotIndex + 1);\n }\n\n return switch (extension)\n {\n case \"mp3\", \"mpga\", \"mpeg\" -> MediaType.parse(\"audio/mpeg\");\n case \"mp4\", \"m4a\" -> MediaType.parse(\"audio/mp4\");\n case \"wav\" -> MediaType.parse(\"audio/wav\");\n case \"webm\" -> MediaType.parse(\"audio/webm\");\n default -> MediaType.parse(\"audio/mpeg\"); // fallback\n };\n }\n\n /**\n * Transcribes the given audio file to text, returning the transcript.\n * Uses OkHttp for multipart/form-data with file, model, and response_format fields.\n */\n @Override\n public String transcribe(Path audioFile) throws IOException\n {\n logger.info(\"Beginning transcription for file: {}\", audioFile);\n File file = audioFile.toFile();\n\n // Use getMediaTypeFromFileName if you want to differentiate .wav vs .mp3, etc.\n // For a simpler approach, you could always use MediaType.get(\"audio/mpeg\") or \"audio/wav\"\n MediaType mediaType = getMediaTypeFromFileName(file.getName());\n RequestBody fileBody = RequestBody.create(file, mediaType);\n\n RequestBody requestBody = new MultipartBody.Builder()\n .setType(MultipartBody.FORM)\n .addFormDataPart(\"file\", file.getName(), fileBody)\n .addFormDataPart(\"model\", \"whisper-1\")\n .addFormDataPart(\"language\", \"en\")\n .addFormDataPart(\"response_format\", \"json\")\n .build();\n\n Request request = new Request.Builder()\n .url(WHISPER_ENDPOINT)\n .header(\"Authorization\", \"Bearer \" + apiKey)\n .addHeader(\"Content-Type\", \"multipart/form-data\")\n .post(requestBody)\n .build();\n\n logger.debug(\"Sending Whisper STT request to {}\", WHISPER_ENDPOINT);\n\n try (okhttp3.Response response = httpClient.newCall(request).execute()) {\n String bodyStr = response.body() != null ? response.body().string() : \"\";\n\n logger.debug(\"Received response, status = {}\", response.code());\n\n if (!response.isSuccessful()) {\n logger.error(\"Whisper STT call failed with status {}: {}\", response.code(), bodyStr);\n throw new IOException(\"Whisper STT call failed with status \"\n + response.code() + \": \" + bodyStr);\n }\n\n // Parse JSON response\n try {\n ObjectMapper mapper = new ObjectMapper();\n JsonNode node = mapper.readTree(bodyStr);\n if (node.has(\"text\")) {\n String transcript = node.get(\"text\").asText().trim();\n logger.info(\"Transcription successful, text length={} chars\", transcript.length());\n return transcript;\n } else {\n logger.warn(\"No 'text' field found in Whisper response.\");\n return \"No transcription found in response.\";\n }\n } catch (Exception e) {\n logger.error(\"Error parsing Whisper JSON response: {}\", e.getMessage());\n throw new IOException(\"Error parsing Whisper JSON response: \" + e.getMessage(), e);\n }\n }\n }\n }\n}\n"}, "bc247e31985a361b403b16c483f5f448e243d8ba": {"t": 1785755618.2553613, "n": 20828, "h": "999910796a895760", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.data.message.SystemMessage;\nimport dev.langchain4j.data.message.UserMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.openai.OpenAiTokenizer;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n ChatLanguageModel searchModel,\n String editModelName,\n String applyModelName,\n String quickModelName,\n String searchModelName)\n{\n public static String[] defaultKeyNames = { \"ANTHROPIC_API_KEY\", \"OPENAI_API_KEY\", \"DEEPSEEK_API_KEY\" };\n\n /**\n * Returns the name of the given model.\n * \n * @param model The model to get the name for\n * @return The name of the model, or \"unknown\" if the model is not recognized\n */\n public String nameOf(Object model) {\n if (model == editModel) return editModelName;\n if (model == applyModel) return applyModelName;\n if (model == quickModel) return quickModelName;\n if (model == searchModel) return searchModelName;\n return \"unknown\";\n }\n // langchain4j only supports openai tokenization, this is not very accurate for other providers\n // but doing loc-based estimation based on information in the responses was worse\n private static final OpenAiTokenizer tokenizer = new OpenAiTokenizer(\"o3-mini\");\n\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n search_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n maxTokens: 8192\n\n search_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n enableCaching: true\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \n \"\"\";\n\n private static final String DEEPSEEK_DEFAULTS = \"\"\"\n edit_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n search_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n apply_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n quick_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>~/.config/brokk/keys.properties</code>, or by <code>System.getenv</code>.\n * </li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = getKey(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = getKey(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n String deepseekKey = getKey(\"DEEPSEEK_API_KEY\");\n if (deepseekKey != null && !deepseekKey.isBlank()) {\n return buildModelsFromYaml(DEEPSEEK_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static String getKey(String keyName) {\n // First try properties file\n try {\n Path keysPath = Project.getLlmKeysPath();\n if (Files.exists(keysPath)) {\n var properties = new java.util.Properties();\n try (var reader = Files.newBufferedReader(keysPath)) {\n properties.load(reader);\n }\n String propValue = properties.getProperty(keyName);\n if (propValue != null && !propValue.isBlank()) {\n return propValue.trim();\n }\n }\n } catch (IOException e) {\n System.err.println(\"Error reading LLM keys: \" + e.getMessage());\n }\n\n // Next try system environment variables\n String envValue = System.getenv(keyName);\n if (envValue != null && !envValue.isBlank()) {\n return envValue.trim();\n }\n\n // Nothing found\n return null;\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n var searchModel = buildChatModel(top, \"search_model\", false);\n\n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n String searchModelName = readModelName(top, \"search_model\");\n\n return new Models(editModel, applyModel, quickModel, searchModel,\n editModelName, applyModelName, quickModelName, searchModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is a property name\n String resolvedKey = getKey(apiKeyOrEnv) == null ? apiKeyOrEnv : getKey(apiKeyOrEnv);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n \n // Enable caching for search model if specified\n if (modelMap.containsKey(\"enableCaching\") && (boolean)modelMap.get(\"enableCaching\")) {\n builder = builder\n .cacheSystemMessages(true);\n // don't cache tools b/c each one is considered a separate \"block\" and anthropic only allows 4 cached blocks\n // .cacheTools(true);\n }\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static String getText(ChatMessage message) {\n return switch (message) {\n case SystemMessage sm -> sm.text();\n case AiMessage am -> am.text();\n case UserMessage um -> um.singleText();\n default -> throw new UnsupportedOperationException(message.getClass().toString());\n };\n }\n\n public static int getApproximateTokens(String text) {\n return tokenizer.encode(text).size();\n }\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }\n}\n"}, "35e3a1b6bd00d08fdbbf1c3bff2f67b08a22356d": {"t": 1785755619.8031282, "n": 20036, "h": "6bb4996e4a4ac7f4", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.data.message.SystemMessage;\nimport dev.langchain4j.data.message.UserMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.openai.OpenAiTokenizer;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n ChatLanguageModel searchModel,\n String editModelName,\n String applyModelName,\n String quickModelName,\n String searchModelName)\n{\n\n /**\n * Returns the name of the given model.\n * \n * @param model The model to get the name for\n * @return The name of the model, or \"unknown\" if the model is not recognized\n */\n public String nameOf(Object model) {\n if (model == editModel) return editModelName;\n if (model == applyModel) return applyModelName;\n if (model == quickModel) return quickModelName;\n if (model == searchModel) return searchModelName;\n return \"unknown\";\n }\n // langchain4j only supports openai tokenization, this is not very accurate for other providers\n // but doing loc-based estimation based on information in the responses was worse\n private static final OpenAiTokenizer tokenizer = new OpenAiTokenizer(\"o3-mini\");\n\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n search_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n maxTokens: 8192\n\n search_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n enableCaching: true\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \n \"\"\";\n\n private static final String DEEPSEEK_DEFAULTS = \"\"\"\n edit_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n search_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n apply_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n quick_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n String deepseekKey = System.getenv(\"DEEPSEEK_API_KEY\");\n if (deepseekKey != null && !deepseekKey.isBlank()) {\n return buildModelsFromYaml(DEEPSEEK_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n var searchModel = buildChatModel(top, \"search_model\", false);\n\n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n String searchModelName = readModelName(top, \"search_model\");\n\n return new Models(editModel, applyModel, quickModel, searchModel,\n editModelName, applyModelName, quickModelName, searchModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n \n // Enable caching for search model if specified\n if (modelMap.containsKey(\"enableCaching\") && (boolean)modelMap.get(\"enableCaching\")) {\n builder = builder\n .cacheSystemMessages(true);\n // don't cache tools b/c each one is considered a separate \"block\" and anthropic only allows 4 cached blocks\n // .cacheTools(true);\n }\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static String getText(ChatMessage message) {\n return switch (message) {\n case SystemMessage sm -> sm.text();\n case AiMessage am -> am.text();\n case UserMessage um -> um.singleText();\n default -> throw new UnsupportedOperationException(message.getClass().toString());\n };\n }\n\n public static int getApproximateTokens(String text) {\n return tokenizer.encode(text).size();\n }\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }\n}\n"}, "f1e06d7f2eb24a70cd31ce2837a544a478a81b4e": {"t": 1785755621.6174347, "n": 19542, "h": "6df0f5482c9de9e7", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.data.message.SystemMessage;\nimport dev.langchain4j.data.message.UserMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.openai.OpenAiTokenizer;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n ChatLanguageModel searchModel,\n String editModelName,\n String applyModelName,\n String quickModelName,\n String searchModelName)\n{\n // langchain4j only supports openai tokenization, this is not very accurate for other providers\n // but doing loc-based estimation based on information in the responses was worse\n private static final OpenAiTokenizer tokenizer = new OpenAiTokenizer(\"o3-mini\");\n\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n search_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n maxTokens: 8192\n\n search_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n enableCaching: true\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \n \"\"\";\n\n private static final String DEEPSEEK_DEFAULTS = \"\"\"\n edit_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n search_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n apply_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n quick_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n String deepseekKey = System.getenv(\"DEEPSEEK_API_KEY\");\n if (deepseekKey != null && !deepseekKey.isBlank()) {\n return buildModelsFromYaml(DEEPSEEK_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n var searchModel = buildChatModel(top, \"search_model\", false);\n\n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n String searchModelName = readModelName(top, \"search_model\");\n\n return new Models(editModel, applyModel, quickModel, searchModel,\n editModelName, applyModelName, quickModelName, searchModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n \n // Enable caching for search model if specified\n if (modelMap.containsKey(\"enableCaching\") && (boolean)modelMap.get(\"enableCaching\")) {\n builder = builder\n .cacheSystemMessages(true);\n // don't cache tools b/c each one is considered a separate \"block\" and anthropic only allows 4 cached blocks\n // .cacheTools(true);\n }\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static String getText(ChatMessage message) {\n return switch (message) {\n case SystemMessage sm -> sm.text();\n case AiMessage am -> am.text();\n case UserMessage um -> um.singleText();\n default -> throw new UnsupportedOperationException(message.getClass().toString());\n };\n }\n\n public static int getApproximateTokens(String text) {\n return tokenizer.encode(text).size();\n }\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }\n}\n"}, "f265bf7fa131ab4c8d3918b9df94baa479d0952e": {"t": 1785755622.9641192, "n": 19922, "h": "a7c2c87e485e87b4", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.data.message.SystemMessage;\nimport dev.langchain4j.data.message.UserMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.openai.OpenAiTokenizer;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n ChatLanguageModel searchModel,\n String editModelName,\n String applyModelName,\n String quickModelName,\n String searchModelName)\n{\n // langchain4j only supports openai tokenization, this is not very accurate for other providers\n // but doing loc-based estimation based on information in the responses was worse\n private static final OpenAiTokenizer tokenizer = new OpenAiTokenizer(\"o3-mini\");\n\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n search_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n maxTokens: 8192\n\n search_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n enableCaching: true\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \n \"\"\";\n\n private static final String DEEPSEEK_DEFAULTS = \"\"\"\n edit_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n search_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n apply_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n quick_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n String deepseekKey = System.getenv(\"DEEPSEEK_API_KEY\");\n if (deepseekKey != null && !deepseekKey.isBlank()) {\n return buildModelsFromYaml(DEEPSEEK_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n var searchModel = buildChatModel(top, \"search_model\", false);\n\n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n String searchModelName = readModelName(top, \"search_model\");\n\n return new Models(editModel, applyModel, quickModel, searchModel,\n editModelName, applyModelName, quickModelName, searchModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .logRequests(true)\n .logResponses(true)\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .logRequests(true)\n .logResponses(true)\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .logRequests(true)\n .logResponses(true)\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .logRequests(true)\n .logResponses(true)\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n \n // Enable caching for search model if specified\n if (modelMap.containsKey(\"enableCaching\") && (boolean)modelMap.get(\"enableCaching\")) {\n builder = builder\n .cacheSystemMessages(true);\n // don't cache tools b/c each one is considered a separate \"block\" and anthropic only allows 4 cached blocks\n // .cacheTools(true);\n }\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static String getText(ChatMessage message) {\n return switch (message) {\n case SystemMessage sm -> sm.text();\n case AiMessage am -> am.text();\n case UserMessage um -> um.singleText();\n default -> throw new UnsupportedOperationException(message.getClass().toString());\n };\n }\n\n public static int getApproximateTokens(String text) {\n return tokenizer.encode(text).size();\n }\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }\n}\n"}, "8ea99f217cd9c1f3a08dc9ae756399a1ec6aa502": {"t": 1785755624.680479, "n": 19542, "h": "6df0f5482c9de9e7", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.data.message.SystemMessage;\nimport dev.langchain4j.data.message.UserMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.openai.OpenAiTokenizer;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n ChatLanguageModel searchModel,\n String editModelName,\n String applyModelName,\n String quickModelName,\n String searchModelName)\n{\n // langchain4j only supports openai tokenization, this is not very accurate for other providers\n // but doing loc-based estimation based on information in the responses was worse\n private static final OpenAiTokenizer tokenizer = new OpenAiTokenizer(\"o3-mini\");\n\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n search_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n maxTokens: 8192\n\n search_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n enableCaching: true\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \n \"\"\";\n\n private static final String DEEPSEEK_DEFAULTS = \"\"\"\n edit_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n search_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n apply_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n quick_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n String deepseekKey = System.getenv(\"DEEPSEEK_API_KEY\");\n if (deepseekKey != null && !deepseekKey.isBlank()) {\n return buildModelsFromYaml(DEEPSEEK_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n var searchModel = buildChatModel(top, \"search_model\", false);\n\n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n String searchModelName = readModelName(top, \"search_model\");\n\n return new Models(editModel, applyModel, quickModel, searchModel,\n editModelName, applyModelName, quickModelName, searchModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n \n // Enable caching for search model if specified\n if (modelMap.containsKey(\"enableCaching\") && (boolean)modelMap.get(\"enableCaching\")) {\n builder = builder\n .cacheSystemMessages(true);\n // don't cache tools b/c each one is considered a separate \"block\" and anthropic only allows 4 cached blocks\n // .cacheTools(true);\n }\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static String getText(ChatMessage message) {\n return switch (message) {\n case SystemMessage sm -> sm.text();\n case AiMessage am -> am.text();\n case UserMessage um -> um.singleText();\n default -> throw new UnsupportedOperationException(message.getClass().toString());\n };\n }\n\n public static int getApproximateTokens(String text) {\n return tokenizer.encode(text).size();\n }\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }\n}\n"}, "5c62941e8bd2528b614b97bb4a8fd6e0717b6d53": {"t": 1785755626.1111708, "n": 19406, "h": "43e31b287688839a", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.data.message.SystemMessage;\nimport dev.langchain4j.data.message.UserMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.openai.OpenAiTokenizer;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n ChatLanguageModel searchModel,\n String editModelName,\n String applyModelName,\n String quickModelName,\n String searchModelName)\n{\n // langchain4j only supports openai tokenization, this is not very accurate for other providers\n // but doing loc-based estimation based on information in the responses was worse\n private static final OpenAiTokenizer tokenizer = new OpenAiTokenizer(\"o3-mini\");\n\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n search_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n maxTokens: 8192\n\n search_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n enableCaching: true\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \n \"\"\";\n\n private static final String DEEPSEEK_DEFAULTS = \"\"\"\n edit_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n search_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n apply_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n quick_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n String deepseekKey = System.getenv(\"DEEPSEEK_API_KEY\");\n if (deepseekKey != null && !deepseekKey.isBlank()) {\n return buildModelsFromYaml(DEEPSEEK_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n var searchModel = buildChatModel(top, \"search_model\", false);\n\n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n String searchModelName = readModelName(top, \"search_model\");\n\n return new Models(editModel, applyModel, quickModel, searchModel,\n editModelName, applyModelName, quickModelName, searchModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n \n // Enable caching for search model if specified\n if (modelMap.containsKey(\"enableCaching\") && (boolean)modelMap.get(\"enableCaching\")) {\n builder = builder\n .cacheSystemMessages(true)\n .cacheTools(true);\n }\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static String getText(ChatMessage message) {\n return switch (message) {\n case SystemMessage sm -> sm.text();\n case AiMessage am -> am.text();\n case UserMessage um -> um.singleText();\n default -> throw new UnsupportedOperationException(message.getClass().toString());\n };\n }\n\n public static int getApproximateTokens(String text) {\n return tokenizer.encode(text).size();\n }\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }\n}\n"}, "88f55adf2dbf9e1b333171387cfc7c8c7b28bb3a": {"t": 1785755627.5684524, "n": 18222, "h": "056c1ef2e50e3835", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.data.message.SystemMessage;\nimport dev.langchain4j.data.message.UserMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.openai.OpenAiTokenizer;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n String editModelName,\n String applyModelName,\n String quickModelName)\n{\n // langchain4j only supports openai tokenization, this is not very accurate for other providers\n // but doing loc-based estimation based on information in the responses was worse\n private static final OpenAiTokenizer tokenizer = new OpenAiTokenizer(\"o3-mini\");\n\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \"\"\";\n\n private static final String DEEPSEEK_DEFAULTS = \"\"\"\n edit_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n apply_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n quick_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n String deepseekKey = System.getenv(\"DEEPSEEK_API_KEY\");\n if (deepseekKey != null && !deepseekKey.isBlank()) {\n return buildModelsFromYaml(DEEPSEEK_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n \n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n \n return new Models(editModel, applyModel, quickModel, editModelName, applyModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static String getText(ChatMessage message) {\n return switch (message) {\n case SystemMessage sm -> sm.text();\n case AiMessage am -> am.text();\n case UserMessage um -> um.singleText();\n default -> throw new UnsupportedOperationException(message.getClass().toString());\n };\n }\n\n public static int getApproximateTokens(String text) {\n return tokenizer.encode(text).size();\n }\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }\n}\n"}, "b000d9d2ae898851f3056f894a7d3243c8f3faa7": {"t": 1785755628.866267, "n": 17785, "h": "2cab4f50a5878c63", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.data.message.SystemMessage;\nimport dev.langchain4j.data.message.UserMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n String editModelName,\n String applyModelName,\n String quickModelName)\n{\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \"\"\";\n\n private static final String DEEPSEEK_DEFAULTS = \"\"\"\n edit_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-reasoner\n maxTokens: 8192\n\n apply_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n\n quick_model:\n provider: deepseek\n key: DEEPSEEK_API_KEY\n name: deepseek-chat\n maxTokens: 8192\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n String deepseekKey = System.getenv(\"DEEPSEEK_API_KEY\");\n if (deepseekKey != null && !deepseekKey.isBlank()) {\n return buildModelsFromYaml(DEEPSEEK_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n \n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n \n return new Models(editModel, applyModel, quickModel, editModelName, applyModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static String getText(ChatMessage message) {\n return switch (message) {\n case SystemMessage sm -> sm.text();\n case AiMessage am -> am.text();\n case UserMessage um -> um.singleText();\n default -> throw new UnsupportedOperationException(message.getClass().toString());\n };\n }\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "7c13b4b928f9e1371e5c14e76ce5579db311aa4e": {"t": 1785755630.3427587, "n": 17165, "h": "c695695528e47651", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.data.message.SystemMessage;\nimport dev.langchain4j.data.message.UserMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n String editModelName,\n String applyModelName,\n String quickModelName)\n{\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n \n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n \n return new Models(editModel, applyModel, quickModel, editModelName, applyModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static String getText(ChatMessage message) {\n return switch (message) {\n case SystemMessage sm -> sm.text();\n case AiMessage am -> am.text();\n case UserMessage um -> um.singleText();\n default -> throw new UnsupportedOperationException(message.getClass().toString());\n };\n }\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "b0006adb4bb9243cd45ba12ed19a9d9f847acca9": {"t": 1785755632.0167577, "n": 16718, "h": "15532ef423697a36", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n String editModelName,\n String applyModelName,\n String quickModelName)\n{\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-7-sonnet-latest\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n \n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n \n return new Models(editModel, applyModel, quickModel, editModelName, applyModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "75d2b30fc4c06c48155af76e3aaf49cec9217862": {"t": 1785755633.4560838, "n": 16718, "h": "340fa86ba006697a", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n String editModelName,\n String applyModelName,\n String quickModelName)\n{\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-sonnet-latest\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n \n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n \n return new Models(editModel, applyModel, quickModel, editModelName, applyModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "e84aad311100eabe8d49a56bdeabbf74f4e7371e": {"t": 1785755634.88821, "n": 16719, "h": "2d31fd4a636cf6f7", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n String editModelName,\n String applyModelName,\n String quickModelName)\n{\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-sonnet-latest\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-sonnet-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n \n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n \n return new Models(editModel, applyModel, quickModel, editModelName, applyModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "5d764d77d2462b4b8f77542da1172a25fc406ba0": {"t": 1785755636.4149334, "n": 16718, "h": "340fa86ba006697a", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n String editModelName,\n String applyModelName,\n String quickModelName)\n{\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-sonnet-latest\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n \n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n \n return new Models(editModel, applyModel, quickModel, editModelName, applyModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(3, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "0aae63e6ffa0853e183d3ce4438cf200c0e1eab2": {"t": 1785755637.919037, "n": 16718, "h": "ad185e7f61d1371e", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n String editModelName,\n String applyModelName,\n String quickModelName)\n{\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-sonnet-latest\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n \n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n \n return new Models(editModel, applyModel, quickModel, editModelName, applyModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n if (modelMap.get(\"reasoning_effort\").toString().equals(\"high\")) {\n // tolerate longer delays\n builder = builder.defaultRequestParameters(reasoningEffort)\n .timeout(Duration.of(2, java.time.temporal.ChronoUnit.MINUTES));\n }\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "f9161993b87c15fe7b37bb1cc1dfcb19887d8e02": {"t": 1785755639.5088346, "n": 16417, "h": "a095053869a0ed10", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n String editModelName,\n String applyModelName,\n String quickModelName)\n{\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-sonnet-latest\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n \n String editModelName = readModelName(top, \"edit_model\");\n String applyModelName = readModelName(top, \"apply_model\");\n String quickModelName = readModelName(top, \"quick_model\");\n \n return new Models(editModel, applyModel, quickModel, editModelName, applyModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey) {\n assert top.containsKey(modelKey); // already built model itself\n\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "4a6cfe6cff41d6f959e2fdf953fb4957d947fb56": {"t": 1785755641.1052468, "n": 16624, "h": "067116f474173d57", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(StreamingChatLanguageModel editModel,\n StreamingChatLanguageModel applyModel,\n ChatLanguageModel quickModel,\n String editModelName,\n String applyModelName,\n String quickModelName)\n{\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n \n apply_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: low\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-sonnet-latest\n maxTokens: 8192\n\n apply_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename define three models: edit_model, apply_model, and quick_model.\n * If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default: omitted).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8k).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n var editModel = buildStreamingModel(top, \"edit_model\", true);\n var applyModel = buildStreamingModel(top, \"apply_model\", false);\n var quickModel = buildChatModel(top, \"quick_model\", false);\n \n String editModelName = readModelName(top, \"edit_model\", \"claude-3-5-sonnet-20241022\");\n String applyModelName = readModelName(top, \"apply_model\", \"o3-mini\");\n String quickModelName = readModelName(top, \"quick_model\", \"claude-3-5-haiku-20241022\");\n \n return new Models(editModel, applyModel, quickModel, editModelName, applyModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableStreamingModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n \"disabled\",\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey, String defaultName) {\n if (!top.containsKey(modelKey)) {\n return defaultName;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n if (configName == null || configName.isBlank()) {\n configName = defaultName;\n }\n if (map.containsKey(\"reasoning_effort\")) {\n String reasoningEffort = (String) map.get(\"reasoning_effort\");\n if (reasoningEffort != null && !reasoningEffort.isBlank()) {\n configName = configName + \"-\" + reasoningEffort;\n }\n }\n return configName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "2df64e35e7994e9b83c37604a25bfed74cfd2167": {"t": 1785755642.5303216, "n": 16479, "h": "5c0b7ddb6a5851df", "c": "package io.github.jbellis.brokk;\n\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiChatRequestParameters;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(ChatLanguageModel editModel,\n StreamingChatLanguageModel editModelStreaming,\n ChatLanguageModel quickModel,\n StreamingChatLanguageModel quickModelStreaming,\n String editModelName,\n String quickModelName)\n{\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n reasoning_effort: high\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-sonnet-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename can define two sets of models: <strong>edit_model</strong> and <strong>quick_model</strong>.\n * Each set is used to build a non-streaming and a streaming model. If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n *\n * quick_model:\n * provider: anthropic\n * key: ANTHROPIC_API_KEY\n * name: claude-v1.3\n * temperature: 0.5\n * maxTokens: 6000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default 0.4).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8000).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n ChatLanguageModel editModel = buildChatModel(top, \"edit_model\", false);\n StreamingChatLanguageModel editModelStreaming = buildStreamingModel(top, \"edit_model\", true);\n ChatLanguageModel quickModel = buildChatModel(top, \"quick_model\", false);\n StreamingChatLanguageModel quickModelStreaming = buildStreamingModel(top, \"quick_model\", true);\n\n // Extract each model name (or use defaults if none specified)\n String editModelName = readModelName(top, \"edit_model\", \"claude-3-5-sonnet-20241022\");\n String quickModelName = readModelName(top, \"quick_model\", \"claude-3-5-haiku-20241022\");\n\n return new Models(editModel, editModelStreaming, quickModel, quickModelStreaming, editModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableStreamingModel(),\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n if (modelMap.get(\"reasoning_effort\") != null) {\n var reasoningEffort = OpenAiChatRequestParameters.builder()\n .reasoningEffort(modelMap.get(\"reasoning_effort\").toString())\n .build();\n builder = builder.defaultRequestParameters(reasoningEffort);\n }\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetDouble(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n private static void maybeSetDouble(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey, String defaultName) {\n if (!top.containsKey(modelKey)) {\n return defaultName;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n return (configName != null && !configName.isBlank()) ? configName : defaultName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "293529e816b7316451facc3a2866532e985e2f70": {"t": 1785755644.0997913, "n": 16271, "h": "878a49ae9faa78ae", "c": "package io.github.jbellis.brokk;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(\n ChatLanguageModel editModel,\n StreamingChatLanguageModel editModelStreaming,\n ChatLanguageModel quickModel,\n StreamingChatLanguageModel quickModelStreaming,\n String editModelName,\n String quickModelName\n) {\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n\n private static final String OPENAI_DEFAULTS = \"\"\"\n edit_model:\n provider: openai\n key: OPENAI_API_KEY\n name: o3-mini\n # reasoning_effort: high # TODO when langchain4j supports it\n maxTokens: 100000\n\n quick_model:\n provider: openai\n key: OPENAI_API_KEY\n name: gpt-4o-mini\n maxTokens: 16384\n \"\"\";\n\n private static final String ANTHROPIC_DEFAULTS = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-sonnet-latest\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-haiku-20240307\n maxTokens: 4096\n \"\"\";\n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename can define two sets of models: <strong>edit_model</strong> and <strong>quick_model</strong>.\n * Each set is used to build a non-streaming and a streaming model. If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n *\n * quick_model:\n * provider: anthropic\n * key: ANTHROPIC_API_KEY\n * name: claude-v1.3\n * temperature: 0.5\n * maxTokens: 6000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default 0.4).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8000).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n // First try loading from yaml file\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (Files.exists(configPath)) {\n try {\n String yamlStr = Files.readString(configPath);\n return buildModelsFromYaml(yamlStr);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n // Fall through to try defaults\n }\n }\n\n // Next try Anthropic if API key exists\n String anthropicKey = System.getenv(\"ANTHROPIC_API_KEY\");\n if (anthropicKey != null && !anthropicKey.isBlank()) {\n return buildModelsFromYaml(ANTHROPIC_DEFAULTS);\n }\n\n // Next try OpenAI if API key exists\n String openaiKey = System.getenv(\"OPENAI_API_KEY\");\n if (openaiKey != null && !openaiKey.isBlank()) {\n return buildModelsFromYaml(OPENAI_DEFAULTS);\n }\n\n // If no API keys are available, return disabled models\n return disabled();\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n ChatLanguageModel editModel = buildChatModel(top, \"edit_model\", false);\n StreamingChatLanguageModel editModelStreaming = buildStreamingModel(top, \"edit_model\", true);\n ChatLanguageModel quickModel = buildChatModel(top, \"quick_model\", false);\n StreamingChatLanguageModel quickModelStreaming = buildStreamingModel(top, \"quick_model\", true);\n\n // Extract each model name (or use defaults if none specified)\n String editModelName = readModelName(top, \"edit_model\", \"claude-3-5-sonnet-20241022\");\n String quickModelName = readModelName(top, \"quick_model\", \"claude-3-5-haiku-20241022\");\n\n return new Models(editModel, editModelStreaming, quickModel, quickModelStreaming, editModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableStreamingModel(),\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n // maybeSetReasoningEffort((String) modelMap.get(\"reasoning_effort\"), builder::reasoningEffort);\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxCompletionTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n // maybeSetReasoningEffort((String) modelMap.get(\"reasoning_effort\"), builder::reasoningEffort);\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n /**\n * Conditionally set temperature on an OpenAI/Anthropic builder if the\n * user actually specified a \"temperature\" in the map.\n */\n private static void maybeSetReasoningEffort(String effort, Consumer<String> setter) {\n if (effort != null && !effort.isBlank()) {\n setter.accept(effort);\n }\n }\n\n private static void maybeSetTemperature(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey, String defaultName) {\n if (!top.containsKey(modelKey)) {\n return defaultName;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n return (configName != null && !configName.isBlank()) ? configName : defaultName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "4c8291d97d46ee7ca854b87400a6b90b7c4e37f3": {"t": 1785755645.459102, "n": 15466, "h": "870886802383b5fc", "c": "package io.github.jbellis.brokk;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(\n ChatLanguageModel editModel,\n StreamingChatLanguageModel editModelStreaming,\n ChatLanguageModel quickModel,\n StreamingChatLanguageModel quickModelStreaming,\n String editModelName,\n String quickModelName\n) {\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n private static final ObjectMapper mapper = new ObjectMapper();\n public static final Map<String, Object> MODEL_METADATA;\n \n /**\n * If the user does not have a config file, or if there's a parse error, fallback to some default.\n */\n private static final String DEFAULT_CONFIG = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-sonnet-20241022\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-20241022\n maxTokens: 8192\n \"\"\";\n\n static {\n try {\n MODEL_METADATA = mapper.readValue(\n Models.class.getResourceAsStream(\"/model-metadata.json\"),\n Map.class\n );\n } catch (Exception e) {\n throw new RuntimeException(\"Failed to load model metadata\", e);\n }\n }\n\n \n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename can define two sets of models: <strong>edit_model</strong> and <strong>quick_model</strong>.\n * Each set is used to build a non-streaming and a streaming model. If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n *\n * quick_model:\n * provider: anthropic\n * key: ANTHROPIC_API_KEY\n * name: claude-v1.3\n * temperature: 0.5\n * maxTokens: 6000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default 0.4).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8000).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n String yamlStr;\n if (!Files.exists(configPath)) {\n yamlStr = DEFAULT_CONFIG;\n } else {\n try {\n yamlStr = Files.readString(configPath);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n yamlStr = DEFAULT_CONFIG;\n }\n }\n\n return buildModelsFromYaml(yamlStr);\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n ChatLanguageModel editModel = buildChatModel(top, \"edit_model\", false);\n StreamingChatLanguageModel editModelStreaming = buildStreamingModel(top, \"edit_model\", true);\n ChatLanguageModel quickModel = buildChatModel(top, \"quick_model\", false);\n StreamingChatLanguageModel quickModelStreaming = buildStreamingModel(top, \"quick_model\", true);\n\n // Extract each model name (or use defaults if none specified)\n String editModelName = readModelName(top, \"edit_model\", \"claude-3-5-sonnet-20241022\");\n String quickModelName = readModelName(top, \"quick_model\", \"claude-3-5-haiku-20241022\");\n\n return new Models(editModel, editModelStreaming, quickModel, quickModelStreaming, editModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n throw new RuntimeException(e); // make compiler happy\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableStreamingModel(),\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n /**\n * Conditionally set temperature on an OpenAI/Anthropic builder if the\n * user actually specified a \"temperature\" in the map.\n */\n private static void maybeSetTemperature(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey, String defaultName) {\n if (!top.containsKey(modelKey)) {\n return defaultName;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n return (configName != null && !configName.isBlank()) ? configName : defaultName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "62b1fd81d3206875ac1022ba22a8e128f7405c9b": {"t": 1785755646.7735717, "n": 15400, "h": "8ee53f7bbe437dfe", "c": "package io.github.jbellis.brokk;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(\n ChatLanguageModel editModel,\n StreamingChatLanguageModel editModelStreaming,\n ChatLanguageModel quickModel,\n StreamingChatLanguageModel quickModelStreaming,\n String editModelName,\n String quickModelName\n) {\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n private static final ObjectMapper mapper = new ObjectMapper();\n public static final Map<String, Object> MODEL_METADATA;\n \n /**\n * If the user does not have a config file, or if there's a parse error, fallback to some default.\n */\n private static final String DEFAULT_CONFIG = \"\"\"\n edit_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-sonnet-20241022\n maxTokens: 8192\n\n quick_model:\n provider: anthropic\n key: ANTHROPIC_API_KEY\n name: claude-3-5-haiku-20241022\n maxTokens: 8192\n \"\"\";\n\n static {\n try {\n MODEL_METADATA = mapper.readValue(\n Models.class.getResourceAsStream(\"/model-metadata.json\"),\n Map.class\n );\n } catch (Exception e) {\n throw new RuntimeException(\"Failed to load model metadata\", e);\n }\n }\n\n \n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename can define two sets of models: <strong>edit_model</strong> and <strong>quick_model</strong>.\n * Each set is used to build a non-streaming and a streaming model. If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n *\n * quick_model:\n * provider: anthropic\n * key: ANTHROPIC_API_KEY\n * name: claude-v1.3\n * temperature: 0.5\n * maxTokens: 6000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default 0.4).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8000).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n String yamlStr;\n if (!Files.exists(configPath)) {\n yamlStr = DEFAULT_CONFIG;\n } else {\n try {\n yamlStr = Files.readString(configPath);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n yamlStr = DEFAULT_CONFIG;\n }\n }\n\n return buildModelsFromYaml(yamlStr);\n }\n\n private static Models buildModelsFromYaml(String yamlStr) {\n try {\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n ChatLanguageModel editModel = buildChatModel(top, \"edit_model\", false);\n StreamingChatLanguageModel editModelStreaming = buildStreamingModel(top, \"edit_model\", true);\n ChatLanguageModel quickModel = buildChatModel(top, \"quick_model\", false);\n StreamingChatLanguageModel quickModelStreaming = buildStreamingModel(top, \"quick_model\", true);\n\n // Extract each model name (or use defaults if none specified)\n String editModelName = readModelName(top, \"edit_model\", \"claude-3-5-sonnet-20241022\");\n String quickModelName = readModelName(top, \"quick_model\", \"claude-3-5-haiku-20241022\");\n\n return new Models(editModel, editModelStreaming, quickModel, quickModelStreaming, editModelName, quickModelName);\n } catch (Exception e) {\n System.out.println(\"Error parsing yaml: \" + e.getMessage());\n System.exit(1);\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableStreamingModel(),\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n /**\n * Conditionally set temperature on an OpenAI/Anthropic builder if the\n * user actually specified a \"temperature\" in the map.\n */\n private static void maybeSetTemperature(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey, String defaultName) {\n if (!top.containsKey(modelKey)) {\n return defaultName;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n return (configName != null && !configName.isBlank()) ? configName : defaultName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "c6e702ab4de242fe7d112d7483d786be2d15c4a6": {"t": 1785755648.1493719, "n": 16229, "h": "1f0ce53c60509452", "c": "package io.github.jbellis.brokk;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.model.ModelDisabledException;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(\n ChatLanguageModel editModel,\n StreamingChatLanguageModel editModelStreaming,\n ChatLanguageModel quickModel,\n StreamingChatLanguageModel quickModelStreaming,\n String editModelName,\n String quickModelName\n) {\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n private static final ObjectMapper mapper = new ObjectMapper();\n public static final Map<String, Object> MODEL_METADATA;\n\n static {\n try {\n MODEL_METADATA = mapper.readValue(\n Models.class.getResourceAsStream(\"/model-metadata.json\"),\n Map.class\n );\n } catch (Exception e) {\n throw new RuntimeException(\"Failed to load model metadata\", e);\n }\n }\n\n \n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename can define two sets of models: <strong>edit_model</strong> and <strong>quick_model</strong>.\n * Each set is used to build a non-streaming and a streaming model. If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n *\n * quick_model:\n * provider: anthropic\n * key: ANTHROPIC_KEY\n * name: claude-v1.3\n * temperature: 0.5\n * maxTokens: 6000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default 0.4).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8000).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (!Files.exists(configPath)) {\n return createDefaultModels();\n }\n\n try {\n String yamlStr = Files.readString(configPath);\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n ChatLanguageModel editModel = buildChatModel(top, \"edit_model\", false);\n StreamingChatLanguageModel editModelStreaming = buildStreamingModel(top, \"edit_model\", true);\n ChatLanguageModel quickModel = buildChatModel(top, \"quick_model\", false);\n StreamingChatLanguageModel quickModelStreaming = buildStreamingModel(top, \"quick_model\", true);\n\n // Extract each model name (or use defaults if none specified)\n String editModelName = readModelName(top, \"edit_model\", \"claude-3-5-sonnet-20241022\");\n String quickModelName = readModelName(top, \"quick_model\", \"claude-3-5-haiku-20241022\");\n\n return new Models(editModel, editModelStreaming, quickModel, quickModelStreaming, editModelName, quickModelName);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n return createDefaultModels();\n } catch (Exception e) {\n System.out.println(\"Error parsing \" + configPath + \": \" + e.getMessage());\n return createDefaultModels();\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableStreamingModel(),\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * If the user does not have a config filename, or if there's a parse error, fallback to some default.\n */\n private static Models createDefaultModels() {\n var editModel = AnthropicChatModel.builder()\n .modelName(\"claude-3-5-sonnet-20241022\")\n .apiKey(System.getenv(\"ANTHROPIC_API_KEY\"))\n .maxTokens(DEFAULT_MAX_TOKENS)\n .build();\n var editStreaming = AnthropicStreamingChatModel.builder()\n .modelName(\"claude-3-5-sonnet-20241022\")\n .apiKey(System.getenv(\"ANTHROPIC_API_KEY\"))\n .maxTokens(DEFAULT_MAX_TOKENS)\n .build();\n var quickModel = AnthropicChatModel.builder()\n .modelName(\"claude-3-5-haiku-20241022\")\n .apiKey(System.getenv(\"ANTHROPIC_API_KEY\"))\n .maxTokens(DEFAULT_MAX_TOKENS)\n .build();\n var quickStreaming = AnthropicStreamingChatModel.builder()\n .modelName(\"claude-3-5-haiku-20241022\")\n .apiKey(System.getenv(\"ANTHROPIC_API_KEY\"))\n .maxTokens(DEFAULT_MAX_TOKENS)\n .build();\n\n return new Models(\n editModel, editStreaming,\n quickModel, quickStreaming,\n \"claude-3-5-sonnet-20241022\",\n \"claude-3-5-haiku-20241022\"\n );\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n /**\n * Conditionally set temperature on an OpenAI/Anthropic builder if the\n * user actually specified a \"temperature\" in the map.\n */\n private static void maybeSetTemperature(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey, String defaultName) {\n if (!top.containsKey(modelKey)) {\n return defaultName;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n return (configName != null && !configName.isBlank()) ? configName : defaultName;\n }\n\n static final String UNAVAILABLE = \"AI is unavailable\";\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return UNAVAILABLE;\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(UNAVAILABLE));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(UNAVAILABLE)));\n }\n }}\n"}, "944edc54a17b7ccf95fb8ca2aa6f583ff80a7b81": {"t": 1785755649.3700104, "n": 16241, "h": "1cb5ca42a05eff50", "c": "package io.github.jbellis.brokk;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport dev.langchain4j.agent.tool.ToolSpecification;\nimport dev.langchain4j.data.message.AiMessage;\nimport dev.langchain4j.data.message.ChatMessage;\nimport dev.langchain4j.model.ModelDisabledException;\nimport dev.langchain4j.model.StreamingResponseHandler;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport dev.langchain4j.model.output.Response;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(\n ChatLanguageModel editModel,\n StreamingChatLanguageModel editModelStreaming,\n ChatLanguageModel quickModel,\n StreamingChatLanguageModel quickModelStreaming,\n String editModelName,\n String quickModelName\n) {\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n private static final ObjectMapper mapper = new ObjectMapper();\n public static final Map<String, Object> MODEL_METADATA;\n\n static {\n try {\n MODEL_METADATA = mapper.readValue(\n Models.class.getResourceAsStream(\"/model-metadata.json\"),\n Map.class\n );\n } catch (Exception e) {\n throw new RuntimeException(\"Failed to load model metadata\", e);\n }\n }\n\n \n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename can define two sets of models: <strong>edit_model</strong> and <strong>quick_model</strong>.\n * Each set is used to build a non-streaming and a streaming model. If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n *\n * quick_model:\n * provider: anthropic\n * key: ANTHROPIC_KEY\n * name: claude-v1.3\n * temperature: 0.5\n * maxTokens: 6000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default 0.4).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8000).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (!Files.exists(configPath)) {\n return createDefaultModels();\n }\n\n try {\n String yamlStr = Files.readString(configPath);\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n ChatLanguageModel editModel = buildChatModel(top, \"edit_model\", false);\n StreamingChatLanguageModel editModelStreaming = buildStreamingModel(top, \"edit_model\", true);\n ChatLanguageModel quickModel = buildChatModel(top, \"quick_model\", false);\n StreamingChatLanguageModel quickModelStreaming = buildStreamingModel(top, \"quick_model\", true);\n\n // Extract each model name (or use defaults if none specified)\n String editModelName = readModelName(top, \"edit_model\", \"claude-3-5-sonnet-20241022\");\n String quickModelName = readModelName(top, \"quick_model\", \"claude-3-5-haiku-20241022\");\n\n return new Models(editModel, editModelStreaming, quickModel, quickModelStreaming, editModelName, quickModelName);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n return createDefaultModels();\n } catch (Exception e) {\n System.out.println(\"Error parsing \" + configPath + \": \" + e.getMessage());\n return createDefaultModels();\n }\n }\n\n public static Models disabled() {\n return new Models(new UnavailableModel(),\n new UnavailableStreamingModel(),\n new UnavailableModel(),\n new UnavailableStreamingModel(),\n \"disabled\",\n \"disabled\");\n }\n\n /**\n * If the user does not have a config filename, or if there's a parse error, fallback to some default.\n */\n private static Models createDefaultModels() {\n var editModel = AnthropicChatModel.builder()\n .modelName(\"claude-3-5-sonnet-20241022\")\n .apiKey(System.getenv(\"ANTHROPIC_API_KEY\"))\n .maxTokens(DEFAULT_MAX_TOKENS)\n .build();\n var editStreaming = AnthropicStreamingChatModel.builder()\n .modelName(\"claude-3-5-sonnet-20241022\")\n .apiKey(System.getenv(\"ANTHROPIC_API_KEY\"))\n .maxTokens(DEFAULT_MAX_TOKENS)\n .build();\n var quickModel = AnthropicChatModel.builder()\n .modelName(\"claude-3-5-haiku-20241022\")\n .apiKey(System.getenv(\"ANTHROPIC_API_KEY\"))\n .maxTokens(DEFAULT_MAX_TOKENS)\n .build();\n var quickStreaming = AnthropicStreamingChatModel.builder()\n .modelName(\"claude-3-5-haiku-20241022\")\n .apiKey(System.getenv(\"ANTHROPIC_API_KEY\"))\n .maxTokens(DEFAULT_MAX_TOKENS)\n .build();\n\n return new Models(\n editModel, editStreaming,\n quickModel, quickStreaming,\n \"claude-3-5-sonnet-20241022\",\n \"claude-3-5-haiku-20241022\"\n );\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n /**\n * Conditionally set temperature on an OpenAI/Anthropic builder if the\n * user actually specified a \"temperature\" in the map.\n */\n private static void maybeSetTemperature(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey, String defaultName) {\n if (!top.containsKey(modelKey)) {\n return defaultName;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n return (configName != null && !configName.isBlank()) ? configName : defaultName;\n }\n\n public static class UnavailableModel implements ChatLanguageModel {\n public UnavailableModel() {\n }\n\n public String generate(String userMessage) {\n return \"AI is unavailable\";\n }\n\n public Response<AiMessage> generate(ChatMessage... messages) {\n return new Response<>(new AiMessage(\"AI is unavailable\"));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages) {\n return new Response<>(new AiMessage(\"AI is unavailable\"));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications) {\n return new Response<>(new AiMessage(\"AI is unavailable\"));\n }\n\n public Response<AiMessage> generate(List<ChatMessage> messages, ToolSpecification toolSpecification) {\n return new Response<>(new AiMessage(\"AI is unavailable\"));\n }\n }\n\n public static class UnavailableStreamingModel implements StreamingChatLanguageModel {\n public UnavailableStreamingModel() {\n }\n\n public void generate(String userMessage, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(\"AI is unavailable\")));\n }\n\n public void generate(List<ChatMessage> messages, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(\"AI is unavailable\")));\n }\n\n public void generate(List<ChatMessage> messages, List<ToolSpecification> toolSpecifications, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(\"AI is unavailable\")));\n }\n\n public void generate(List<ChatMessage> messages, ToolSpecification toolSpecification, StreamingResponseHandler<AiMessage> handler) {\n handler.onComplete(new Response<>(new AiMessage(\"AI is unavailable\")));\n }\n }}\n"}, "eaba752e8112ec55c980b4bbec79b4d7d49c529a": {"t": 1785755650.753076, "n": 13624, "h": "f99f7296bb35fda4", "c": "package io.github.jbellis.brokk;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport dev.langchain4j.model.anthropic.AnthropicChatModel;\nimport dev.langchain4j.model.anthropic.AnthropicStreamingChatModel;\nimport dev.langchain4j.model.chat.ChatLanguageModel;\nimport dev.langchain4j.model.chat.StreamingChatLanguageModel;\nimport dev.langchain4j.model.openai.OpenAiChatModel;\nimport dev.langchain4j.model.openai.OpenAiStreamingChatModel;\nimport org.yaml.snakeyaml.Yaml;\n\nimport java.io.IOException;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.util.Map;\nimport java.util.function.Consumer;\n\n/**\n * Holds references to the models we use in Brokk.\n */\npublic record Models(\n ChatLanguageModel editModel,\n StreamingChatLanguageModel editModelStreaming,\n ChatLanguageModel quickModel,\n StreamingChatLanguageModel quickModelStreaming,\n String editModelName,\n String quickModelName\n) {\n // correct for these models\n private static final int DEFAULT_MAX_TOKENS = 8192;\n private static final ObjectMapper mapper = new ObjectMapper();\n public static final Map<String, Object> MODEL_METADATA;\n\n static {\n try {\n MODEL_METADATA = mapper.readValue(\n Models.class.getResourceAsStream(\"/model-metadata.json\"),\n Map.class\n );\n } catch (Exception e) {\n throw new RuntimeException(\"Failed to load model metadata\", e);\n }\n }\n\n \n\n /**\n * Attempts to load model configurations from <code>~/.config/brokk/brokk.yaml</code>.\n * <p>\n * The YAML filename can define two sets of models: <strong>edit_model</strong> and <strong>quick_model</strong>.\n * Each set is used to build a non-streaming and a streaming model. If any model definition is missing or\n * the filename cannot be read or parsed, default settings are used.\n * <p>\n * <strong>Example YAML structure:</strong>\n * <pre>\n * edit_model:\n * provider: deepseek\n * key: DEEPSEEK_API_KEY\n * url: https://api.deepseek.com\n * name: deepseek-chat\n * temperature: 0.4\n * maxTokens: 8000\n *\n * quick_model:\n * provider: anthropic\n * key: ANTHROPIC_KEY\n * name: claude-v1.3\n * temperature: 0.5\n * maxTokens: 6000\n * </pre>\n * <ul>\n * <li><strong>provider:</strong> Required if you want to use a known provider\n * (<code>openai</code>, <code>deepseek</code>, <code>anthropic</code>). If omitted, the code falls back\n * to interpreting <em>url</em> + <em>key</em> as a custom OpenAI-like endpoint or uses\n * a built-in OpenAI default.</li>\n * <li><strong>key:</strong> Indicates the environment variable holding the API key (e.g., <code>DEEPSEEK_API_KEY</code>).\n * This is looked up via <code>System.getenv</code>. If <code>key</code> is <code>env:SOME_VAR</code>,\n * the portion after <code>env:</code> is taken as the environment variable name. Otherwise, the entire\n * <code>key</code> string is treated as the environment variable name directly.</li>\n * <li><strong>url:</strong> Optional. If using <code>deepseek</code>, for instance, defaults to\n * <code>https://api.deepseek.com</code> unless otherwise specified. OpenAI can omit <em>url</em>\n * to use the default OpenAI endpoint.</li>\n * <li><strong>name:</strong> The model identifier (e.g., <code>gpt-3.5-turbo</code>, <code>deepseek-chat</code>,\n * <code>claude-v1.3</code>, etc.).</li>\n * <li><strong>temperature:</strong> A double value controlling the \"creativity\" of responses (default 0.4).</li>\n * <li><strong>maxTokens:</strong> The maximum number of tokens to allow in the response (default 8000).</li>\n * </ul>\n * If the filename is missing or cannot be parsed, the method returns a {@link Models} instance with\n * hardcoded default model settings (currently using Anthropic-based defaults).\n *\n * @return A {@link Models} record containing references to the edit and quick Chat/StreamingChat models,\n * plus each model's name.\n */\n public static Models load() {\n Path configPath = Path.of(System.getProperty(\"user.home\"), \".config\", \"brokk\", \"brokk.yml\");\n if (!Files.exists(configPath)) {\n return createDefaultModels();\n }\n\n try {\n String yamlStr = Files.readString(configPath);\n Yaml yaml = new Yaml();\n Map<String, Object> top = yaml.load(yamlStr);\n\n // Build each model\n ChatLanguageModel editModel = buildChatModel(top, \"edit_model\", false);\n StreamingChatLanguageModel editModelStreaming = buildStreamingModel(top, \"edit_model\", true);\n ChatLanguageModel quickModel = buildChatModel(top, \"quick_model\", false);\n StreamingChatLanguageModel quickModelStreaming = buildStreamingModel(top, \"quick_model\", true);\n\n // Extract each model name (or use defaults if none specified)\n String editModelName = readModelName(top, \"edit_model\", \"claude-3-5-sonnet-20241022\");\n String quickModelName = readModelName(top, \"quick_model\", \"claude-3-5-haiku-20241022\");\n\n return new Models(editModel, editModelStreaming, quickModel, quickModelStreaming, editModelName, quickModelName);\n } catch (IOException e) {\n System.out.println(\"Error reading \" + configPath + \": \" + e.getMessage());\n return createDefaultModels();\n } catch (Exception e) {\n System.out.println(\"Error parsing \" + configPath + \": \" + e.getMessage());\n return createDefaultModels();\n }\n }\n\n /**\n * If the user does not have a config filename, or if there's a parse error, fallback to some default.\n */\n private static Models createDefaultModels() {\n var editModel = AnthropicChatModel.builder()\n .modelName(\"claude-3-5-sonnet-20241022\")\n .apiKey(System.getenv(\"ANTHROPIC_API_KEY\"))\n .maxTokens(DEFAULT_MAX_TOKENS)\n .build();\n var editStreaming = AnthropicStreamingChatModel.builder()\n .modelName(\"claude-3-5-sonnet-20241022\")\n .apiKey(System.getenv(\"ANTHROPIC_API_KEY\"))\n .maxTokens(DEFAULT_MAX_TOKENS)\n .build();\n var quickModel = AnthropicChatModel.builder()\n .modelName(\"claude-3-5-haiku-20241022\")\n .apiKey(System.getenv(\"ANTHROPIC_API_KEY\"))\n .maxTokens(DEFAULT_MAX_TOKENS)\n .build();\n var quickStreaming = AnthropicStreamingChatModel.builder()\n .modelName(\"claude-3-5-haiku-20241022\")\n .apiKey(System.getenv(\"ANTHROPIC_API_KEY\"))\n .maxTokens(DEFAULT_MAX_TOKENS)\n .build();\n\n return new Models(\n editModel, editStreaming,\n quickModel, quickStreaming,\n \"claude-3-5-sonnet-20241022\",\n \"claude-3-5-haiku-20241022\"\n );\n }\n\n /**\n * Build a non-streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static ChatLanguageModel buildChatModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (ChatLanguageModel) buildModelFromMap(map, false);\n }\n\n /**\n * Build a streaming ChatLanguageModel.\n */\n @SuppressWarnings(\"unchecked\")\n private static StreamingChatLanguageModel buildStreamingModel(Map<String, Object> top, String key, boolean streaming) {\n if (!top.containsKey(key)) {\n return null;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(key);\n return (StreamingChatLanguageModel) buildModelFromMap(map, true);\n }\n\n /**\n * Internal helper: build either ChatLanguageModel or StreamingChatLanguageModel from the map.\n */\n private static Object buildModelFromMap(Map<String, Object> modelMap, boolean streaming) {\n // Read common fields\n String provider = (String) modelMap.get(\"provider\"); // optional\n String url = (String) modelMap.get(\"url\"); // optional\n String apiKeyOrEnv = (String) modelMap.get(\"key\"); // optional, can be an env var name\n String modelName = (String) modelMap.get(\"name\"); // optional\n Double temperature = getDouble(modelMap, \"temperature\", null); // don't hardcode a default\n int maxTokens = getInt(modelMap, \"maxTokens\", DEFAULT_MAX_TOKENS);\n\n // Resolve the actual API key if \"key\" is an env var name\n String resolvedKey = (apiKeyOrEnv != null && apiKeyOrEnv.startsWith(\"env:\"))\n ? System.getenv(apiKeyOrEnv.substring(\"env:\".length()))\n : (apiKeyOrEnv != null ? System.getenv(apiKeyOrEnv) : null);\n\n if (provider != null && provider.equalsIgnoreCase(\"deepseek\")) {\n provider = null;\n url = \"https://api.deepseek.com\";\n }\n\n if ((provider == null || provider.isBlank()) && url == null) {\n throw new IllegalArgumentException(\"Either provider or url must be specified\");\n }\n if (provider != null && url != null) {\n throw new IllegalArgumentException(\"Exactly one of provider and url must be specified\");\n }\n\n // No provider => use openai client with url\n if (provider == null || provider.isBlank()) {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n return builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .baseUrl(url)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n return builder.build();\n }\n }\n\n // Otherwise, provider is set: handle openai or anthropic\n return switch (provider.toLowerCase()) {\n case \"openai\" -> {\n if (streaming) {\n var builder = OpenAiStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = OpenAiChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n }\n }\n case \"anthropic\" -> {\n if (streaming) {\n var builder = AnthropicStreamingChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n } else {\n var builder = AnthropicChatModel.builder()\n .apiKey(resolvedKey)\n .modelName(modelName)\n .maxTokens(maxTokens);\n maybeSetTemperature(temperature, builder::temperature);\n yield builder.build();\n }\n }\n default -> throw new IllegalArgumentException(\"Unknown provider: \" + provider);\n };\n }\n\n /**\n * Conditionally set temperature on an OpenAI/Anthropic builder if the\n * user actually specified a \"temperature\" in the map.\n */\n private static void maybeSetTemperature(Double temperature, Consumer<Double> setter) {\n if (temperature != null) {\n setter.accept(temperature);\n }\n }\n\n private static Double getDouble(Map<String, Object> map, String key, Double defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).doubleValue();\n }\n return defaultValue; \n }\n\n private static int getInt(Map<String, Object> map, String key, int defaultValue) {\n Object val = map.get(key);\n if (val instanceof Number) {\n return ((Number) val).intValue();\n }\n return defaultValue;\n }\n\n /**\n * Helper method to pull a \"name\" field from a sub-map, falling back to a default if missing.\n */\n @SuppressWarnings(\"unchecked\")\n private static String readModelName(Map<String, Object> top, String modelKey, String defaultName) {\n if (!top.containsKey(modelKey)) {\n return defaultName;\n }\n Map<String, Object> map = (Map<String, Object>) top.get(modelKey);\n String configName = (String) map.get(\"name\");\n return (configName != null && !configName.isBlank()) ? configName : defaultName;\n }\n}\n"}}}} |