feat: support deepseek and openai-compatible providers

This commit is contained in:
liumangmang
2026-04-29 22:19:00 +08:00
parent 4ac755a7fe
commit 3555d19b26
13 changed files with 761 additions and 190 deletions
+7 -4
View File
@@ -2,7 +2,7 @@
## 功能说明
通过 Web 工作台上传/选择 Markdown 日志,调用 DeepSeek API 分析并生成 Excel 工作量统计文件。
通过 Web 工作台上传/选择 Markdown 日志,调用 DeepSeek 或 OpenAI 兼容 API 分析并生成 Excel 工作量统计文件。
## 启动
@@ -22,11 +22,13 @@ mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
## 使用步骤
1. 在「SVN 日志抓取」先生成 `.md` 文件
2. 在「系统设置」配置 DeepSeek API Key(或使用环境变量 `DEEPSEEK_API_KEY`
2. 在「系统设置」选择 AI 提供商:
- `DeepSeek`:配置 DeepSeek API Key(或使用环境变量 `DEEPSEEK_API_KEY`
- `OpenAI兼容`:配置 `Base URL``API Key`、第一阶段模型、第二阶段模型
3. 在「AI 工作量分析」选择 `.md` 文件并发起分析
4. 在「任务历史」或「产物列表」下载 `.xlsx`
## API Key 读取优先级
## DeepSeek API Key 读取优先级
1. 请求中的 `apiKey`
2. 设置页保存的运行时 `apiKey`
@@ -35,5 +37,6 @@ mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
## 注意事项
- 不要在源码和日志中写入真实密钥
- 需要可访问 DeepSeek API 的网络环境
- DeepSeek 模式需要可访问 DeepSeek API 的网络环境
- OpenAI 兼容模式要求兼容服务提供 `/chat/completions` 流式接口
- 接口调用可能产生费用,建议控制调用频率
+12 -3
View File
@@ -6,7 +6,7 @@ Web 工作台将现有 CLI 能力封装为可视化页面与 REST API,支持
1. SVN 参数录入与连接测试
2. 异步抓取日志并导出 Markdown
3. 使用 DeepSeek 分析 Markdown 并生成 Excel
3. 使用 DeepSeek 或 OpenAI 兼容接口分析 Markdown 并生成 Excel
4. 查看任务历史(状态、进度、错误、产物),支持筛选、分页与取消运行中任务
5. 下载输出文件、配置 API Key 与输出目录
6. 工作台展示系统健康状态(输出目录可写性、API Key 配置、任务统计)
@@ -40,7 +40,7 @@ http://localhost:18088
- SVN 日志抓取:SVN 地址、账号密码、版本区间、过滤用户(支持预置项目下拉与自定义地址)
- AI 工作量分析:选择 Markdown 文件、工作周期、输出文件名
- 任务历史:异步任务状态与产物列表,支持筛选、分页、取消任务
- 系统设置:DeepSeek API Key、输出目录、默认 SVN 预置项目
- 系统设置:AI 提供商、DeepSeek API Key、OpenAI 兼容 Base URL/API Key/阶段模型、输出目录、默认 SVN 预置项目
## 输出目录
@@ -49,7 +49,16 @@ http://localhost:18088
- Excel 输出:`outputs/excel/*.xlsx`
- 任务持久化:`outputs/task-history.json`(重启后可恢复历史)
## API Key 读取优先级
## AI 提供商设置
- `DeepSeek`:沿用现有两阶段分析链路,读取 DeepSeek API Key
- `OpenAI兼容`:使用 `baseURL + apiKey + stage1Model + stage2Model` 调用兼容 `/chat/completions` 接口
- OpenAI 兼容默认值:
- `baseURL=http://127.0.0.1:5001/v1`
- `stage1Model=deepseek-v4-flash`
- `stage2Model=deepseek-v4-pro`
## DeepSeek API Key 读取优先级
1. AI 分析请求中的临时 `apiKey`
2. 设置页保存的运行时 `apiKey`
+6
View File
@@ -126,6 +126,12 @@
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
@@ -266,7 +266,16 @@ public class AppController {
@PutMapping("/settings")
public Map<String, Object> updateSettings(@RequestBody SettingsUpdateRequest request) throws IOException {
settingsService.updateSettings(request.getApiKey(), request.getOutputDir(), request.getDefaultSvnPresetId());
settingsService.updateSettings(
request.getApiKey(),
request.getProvider(),
request.getOpenaiBaseUrl(),
request.getOpenaiApiKey(),
request.getOpenaiStageOneModel(),
request.getOpenaiStageTwoModel(),
request.getOutputDir(),
request.getDefaultSvnPresetId()
);
return settingsService.getSettings();
}
@@ -3,6 +3,11 @@ package com.svnlog.web.dto;
public class SettingsUpdateRequest {
private String apiKey;
private String provider;
private String openaiBaseUrl;
private String openaiApiKey;
private String openaiStageOneModel;
private String openaiStageTwoModel;
private String outputDir;
private String defaultSvnPresetId;
@@ -14,6 +19,46 @@ public class SettingsUpdateRequest {
this.apiKey = apiKey;
}
public String getProvider() {
return provider;
}
public void setProvider(String provider) {
this.provider = provider;
}
public String getOpenaiBaseUrl() {
return openaiBaseUrl;
}
public void setOpenaiBaseUrl(String openaiBaseUrl) {
this.openaiBaseUrl = openaiBaseUrl;
}
public String getOpenaiApiKey() {
return openaiApiKey;
}
public void setOpenaiApiKey(String openaiApiKey) {
this.openaiApiKey = openaiApiKey;
}
public String getOpenaiStageOneModel() {
return openaiStageOneModel;
}
public void setOpenaiStageOneModel(String openaiStageOneModel) {
this.openaiStageOneModel = openaiStageOneModel;
}
public String getOpenaiStageTwoModel() {
return openaiStageTwoModel;
}
public void setOpenaiStageTwoModel(String openaiStageTwoModel) {
this.openaiStageTwoModel = openaiStageTwoModel;
}
public String getOutputDir() {
return outputDir;
}
@@ -119,17 +119,13 @@ public class AiWorkflowService {
final Map<String, List<CommitEntry>> projectCommits = buildProjectCommitMap(content);
final Map<String, Integer> projectLogCounts = buildProjectLogCountsFromCommits(projectCommits);
final Map<String, Integer> projectMinItems = buildProjectMinItems(projectLogCounts);
final AiProviderContext providerContext = resolveProviderContext(request.getApiKey());
context.setProgress(35, "正在请求 DeepSeek 分析");
context.setProgress(35, "正在请求 AI 分析");
final String period = request.getPeriod() != null && !request.getPeriod().trim().isEmpty()
? request.getPeriod().trim()
: new SimpleDateFormat("yyyy年MM月").format(new Date());
final String apiKey = settingsService.pickActiveKey(request.getApiKey());
if (apiKey == null || apiKey.trim().isEmpty()) {
throw new IllegalStateException("未配置 DeepSeek API Key(可在设置页配置或请求中传入)");
}
final Map<String, LinkedHashSet<String>> groupedFromAi = createGroupedItems();
for (int i = 0; i < FIXED_PROJECTS.length; i++) {
final String project = FIXED_PROJECTS[i];
@@ -143,11 +139,16 @@ public class AiWorkflowService {
List<ProjectSummaryItem> stageOneItems = new ArrayList<ProjectSummaryItem>();
try {
final String stageOnePrompt = buildProjectCompressionPrompt(project, sourceCommits, period, targetItems);
final String stageOneResponse = callDeepSeek(apiKey, stageOnePrompt, context, DEEPSEEK_MODEL_CHAT);
final String stageOneResponse = callAi(providerContext, stageOnePrompt, context, 1);
stageOneItems = parseProjectSummaryItems(stageOneResponse, project);
} catch (Exception ex) {
LOGGER.warn("DeepSeek chat stage failed, fallback to local compression: project={}", project, ex);
context.emitEvent("phase", buildEventPayload("Chat 压缩失败,已切换本地压缩: " + project));
LOGGER.warn("{} stage one failed, fallback to local compression: project={}",
providerContext.displayName,
project,
ex);
context.emitEvent("phase", buildEventPayload(
providerContext.displayName + " 压缩失败,已切换本地压缩: " + project
));
}
if (stageOneItems.isEmpty()) {
@@ -164,12 +165,17 @@ public class AiWorkflowService {
context.setProgress(stepProgress + 5, "正在执行 Think 精炼总结: " + project);
try {
final String stageTwoPrompt = buildProjectRefinePrompt(project, period, targetItems, sourceCommits, stageOneItems);
final String stageTwoResponse = callDeepSeek(apiKey, stageTwoPrompt, context, DEEPSEEK_MODEL_THINK);
final String stageTwoResponse = callAi(providerContext, stageTwoPrompt, context, 2);
final List<ProjectSummaryItem> stageTwoItems = parseProjectSummaryItems(stageTwoResponse, project);
groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageTwoItems, targetItems));
} catch (Exception ex) {
LOGGER.warn("DeepSeek think stage failed, fallback to chat merge: project={}", project, ex);
context.emitEvent("phase", buildEventPayload("Think 精炼失败,使用 Chat 压缩结果: " + project));
LOGGER.warn("{} stage two failed, fallback to stage one merge: project={}",
providerContext.displayName,
project,
ex);
context.emitEvent("phase", buildEventPayload(
providerContext.displayName + " 精炼失败,使用阶段一压缩结果: " + project
));
groupedFromAi.get(project).addAll(mergeProjectItems(project, sourceCommits, stageOneItems, targetItems));
}
}
@@ -824,13 +830,72 @@ public class AiWorkflowService {
return count;
}
private String callDeepSeek(String apiKey, String prompt, TaskContext context, String model) throws IOException {
final String modelName = model == null || model.trim().isEmpty() ? DEEPSEEK_MODEL_CHAT : model.trim();
final int primaryMaxTokens = resolvePrimaryMaxTokens(modelName);
final int retryMaxTokens = resolveRetryMaxTokens(modelName);
String normalizeChatCompletionsUrl(String baseUrl) {
final String normalized = baseUrl == null ? "" : baseUrl.trim();
if (normalized.isEmpty()) {
return "";
}
String value = normalized;
while (value.endsWith("/")) {
value = value.substring(0, value.length() - 1);
}
if (value.endsWith("/chat/completions")) {
return value;
}
return value + "/chat/completions";
}
AiProviderContext resolveProviderContext(String requestApiKey) {
final String provider = settingsService.getProvider();
if (SettingsService.PROVIDER_OPENAI_COMPATIBLE.equals(provider)) {
final String baseUrl = settingsService.getOpenaiBaseUrl();
final String apiKey = settingsService.getOpenaiApiKey();
if (baseUrl == null || baseUrl.trim().isEmpty()) {
throw new IllegalStateException("未配置 OpenAI兼容 Base URL(请先在系统设置中保存)");
}
if (apiKey == null || apiKey.trim().isEmpty()) {
throw new IllegalStateException("未配置 OpenAI兼容 API Key(请先在系统设置中保存)");
}
return new AiProviderContext(
provider,
"OpenAI兼容",
normalizeChatCompletionsUrl(baseUrl),
apiKey.trim(),
settingsService.getOpenaiStageOneModel(),
settingsService.getOpenaiStageTwoModel()
);
}
final String apiKey = settingsService.pickActiveKey(requestApiKey);
if (apiKey == null || apiKey.trim().isEmpty()) {
throw new IllegalStateException("未配置 DeepSeek API Key(可在设置页配置或请求中传入)");
}
return new AiProviderContext(
SettingsService.PROVIDER_DEEPSEEK,
"DeepSeek",
DEEPSEEK_API_URL,
apiKey.trim(),
DEEPSEEK_MODEL_CHAT,
DEEPSEEK_MODEL_THINK
);
}
private String callAi(AiProviderContext providerContext,
String prompt,
TaskContext context,
int stageNumber) throws IOException {
final String modelName = stageNumber == 2
? providerContext.stageTwoModel
: providerContext.stageOneModel;
final int primaryMaxTokens = stageNumber == 2
? DEEPSEEK_REASONER_MAX_TOKENS_PRIMARY
: DEEPSEEK_CHAT_MAX_TOKENS_PRIMARY;
final int retryMaxTokens = stageNumber == 2
? DEEPSEEK_REASONER_MAX_TOKENS_RETRY
: DEEPSEEK_CHAT_MAX_TOKENS_RETRY;
try {
final DeepSeekStreamResult primary = retrySupport.execute(
() -> callDeepSeekOnce(apiKey, prompt, context, modelName, primaryMaxTokens),
final AiStreamResult primary = retrySupport.execute(
() -> callAiOnce(providerContext, prompt, context, modelName, primaryMaxTokens),
3,
1000L
);
@@ -845,16 +910,17 @@ public class AiWorkflowService {
}
context.emitEvent("phase", buildEventPayload(
"DeepSeek(" + modelName + ") 输出长度触顶,自动重试(max_tokens=" + retryMaxTokens + ""
providerContext.displayName
+ "(" + modelName + ") 输出长度触顶,自动重试(max_tokens=" + retryMaxTokens + ""
));
final DeepSeekStreamResult retried = retrySupport.execute(
() -> callDeepSeekOnce(apiKey, prompt, context, modelName, retryMaxTokens),
final AiStreamResult retried = retrySupport.execute(
() -> callAiOnce(providerContext, prompt, context, modelName, retryMaxTokens),
2,
1200L
);
if ("length".equalsIgnoreCase(retried.finishReason) && !isValidJsonObjectText(retried.answer)) {
throw new IllegalStateException(
"DeepSeek 输出被截断(finish_reason=length),请缩短输入日志范围后重试"
providerContext.displayName + " 输出被截断(finish_reason=length),请缩短输入日志范围后重试"
);
}
return retried.answer;
@@ -867,25 +933,11 @@ public class AiWorkflowService {
}
}
private int resolvePrimaryMaxTokens(String modelName) {
if (DEEPSEEK_MODEL_THINK.equalsIgnoreCase(modelName)) {
return DEEPSEEK_REASONER_MAX_TOKENS_PRIMARY;
}
return DEEPSEEK_CHAT_MAX_TOKENS_PRIMARY;
}
private int resolveRetryMaxTokens(String modelName) {
if (DEEPSEEK_MODEL_THINK.equalsIgnoreCase(modelName)) {
return DEEPSEEK_REASONER_MAX_TOKENS_RETRY;
}
return DEEPSEEK_CHAT_MAX_TOKENS_RETRY;
}
private DeepSeekStreamResult callDeepSeekOnce(String apiKey,
String prompt,
TaskContext context,
String model,
int maxTokens) throws Exception {
private AiStreamResult callAiOnce(AiProviderContext providerContext,
String prompt,
TaskContext context,
String model,
int maxTokens) throws Exception {
final JsonObject message = new JsonObject();
message.addProperty("role", "user");
message.addProperty("content", prompt);
@@ -906,8 +958,8 @@ public class AiWorkflowService {
body.add("stream_options", streamOptions);
final Request request = new Request.Builder()
.url(DEEPSEEK_API_URL)
.addHeader("Authorization", "Bearer " + apiKey)
.url(providerContext.apiUrl)
.addHeader("Authorization", "Bearer " + providerContext.apiKey)
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(body.toString(), MediaType.parse("application/json")))
.build();
@@ -918,129 +970,136 @@ public class AiWorkflowService {
if (response.body() != null) {
errorBody = response.body().string();
}
String detail = "DeepSeek API 调用失败: " + response.code() + " " + errorBody;
String detail = providerContext.displayName + " API 调用失败: " + response.code() + " " + errorBody;
if (response.code() == 429 || response.code() >= 500) {
throw new RetrySupport.RetryableException(detail);
}
throw new IllegalStateException(detail);
}
if (response.body() == null) {
throw new RetrySupport.RetryableException("DeepSeek API 返回空响应体");
throw new RetrySupport.RetryableException(providerContext.displayName + " API 返回空响应体");
}
final StringBuilder answerBuilder = new StringBuilder();
final StringBuilder reasoningBuilder = new StringBuilder();
final okhttp3.ResponseBody responseBody = response.body();
final okio.BufferedSource source = responseBody.source();
String finishReason = "";
int reasoningDeltaCount = 0;
int answerDeltaCount = 0;
Long usagePromptTokens = null;
Long usageCompletionTokens = null;
Long usageTotalTokens = null;
String finalMessageContent = "";
context.emitEvent("phase", buildEventPayload("正在流式接收 DeepSeek 输出"));
while (!source.exhausted()) {
final String line = source.readUtf8Line();
if (line == null || line.trim().isEmpty() || !line.startsWith("data:")) {
continue;
}
final String dataLine = line.substring(5).trim();
if ("[DONE]".equals(dataLine)) {
break;
}
final JsonObject data = JsonParser.parseString(dataLine).getAsJsonObject();
if (data.has("usage") && data.get("usage").isJsonObject()) {
final JsonObject usage = data.getAsJsonObject("usage");
usagePromptTokens = optLong(usage, "prompt_tokens");
usageCompletionTokens = optLong(usage, "completion_tokens");
usageTotalTokens = optLong(usage, "total_tokens");
final Map<String, Object> usagePayload = new LinkedHashMap<String, Object>();
usagePayload.put("promptTokens", usagePromptTokens);
usagePayload.put("completionTokens", usageCompletionTokens);
usagePayload.put("totalTokens", usageTotalTokens);
usagePayload.put("cacheHitTokens", optLong(usage, "prompt_cache_hit_tokens"));
usagePayload.put("cacheMissTokens", optLong(usage, "prompt_cache_miss_tokens"));
context.emitEvent("usage", usagePayload);
}
final JsonArray choices = data.getAsJsonArray("choices");
if (choices == null || choices.size() == 0) {
continue;
}
final JsonObject first = choices.get(0).getAsJsonObject();
if (first.has("message") && first.get("message").isJsonObject()) {
final String content = optString(first.getAsJsonObject("message"), "content");
if (content != null && !content.trim().isEmpty()) {
finalMessageContent = content.trim();
}
}
if (first.has("delta") && first.get("delta").isJsonObject()) {
final JsonObject delta = first.getAsJsonObject("delta");
final String reasoning = optString(delta, "reasoning_content");
if (reasoning != null && !reasoning.isEmpty()) {
reasoningDeltaCount++;
reasoningBuilder.append(reasoning);
context.emitEvent("reasoning_delta", buildTextPayload(reasoning));
if (reasoningDeltaCount % STREAM_PERSIST_INTERVAL == 0) {
context.updateAiOutput(reasoningBuilder.toString(), answerBuilder.toString());
}
}
final String answer = optString(delta, "content");
if (answer != null && !answer.isEmpty()) {
answerDeltaCount++;
answerBuilder.append(answer);
context.emitEvent("answer_delta", buildTextPayload(answer));
if (answerDeltaCount % STREAM_PERSIST_INTERVAL == 0) {
context.updateAiOutput(reasoningBuilder.toString(), answerBuilder.toString());
}
}
}
if (first.has("finish_reason") && !first.get("finish_reason").isJsonNull()) {
finishReason = first.get("finish_reason").getAsString();
}
}
if ("insufficient_system_resource".equalsIgnoreCase(finishReason)) {
throw new RetrySupport.RetryableException("DeepSeek 资源不足,请稍后重试");
}
String answer = answerBuilder.toString().trim();
if (answer.isEmpty() && finalMessageContent != null && !finalMessageContent.isEmpty()) {
answer = finalMessageContent;
}
if (answer.isEmpty()) {
throw new IllegalStateException(
"DeepSeek 未返回有效 content 内容"
+ " | stage_model=" + model
+ " | api_model=" + model
+ " | finish_reason=" + finishReason
+ " | prompt_tokens=" + usagePromptTokens
+ " | completion_tokens=" + usageCompletionTokens
+ " | total_tokens=" + usageTotalTokens
);
}
context.updateAiOutput(reasoningBuilder.toString(), answer);
context.setAiStreamStatus("DONE");
LOGGER.info(
"DeepSeek stream deltas: model={}, reasoning={}, answer={}, finishReason={}, maxTokens={}",
model,
reasoningDeltaCount,
answerDeltaCount,
finishReason,
maxTokens
);
return new DeepSeekStreamResult(answer, finishReason);
return readStreamingResponse(responseBody.source(), context, providerContext, model, maxTokens);
}
}
AiStreamResult readStreamingResponse(okio.BufferedSource source,
TaskContext context,
AiProviderContext providerContext,
String model,
int maxTokens) throws Exception {
final StringBuilder answerBuilder = new StringBuilder();
final StringBuilder reasoningBuilder = new StringBuilder();
String finishReason = "";
int reasoningDeltaCount = 0;
int answerDeltaCount = 0;
Long usagePromptTokens = null;
Long usageCompletionTokens = null;
Long usageTotalTokens = null;
String finalMessageContent = "";
context.emitEvent("phase", buildEventPayload("正在流式接收 " + providerContext.displayName + " 输出"));
while (!source.exhausted()) {
final String line = source.readUtf8Line();
if (line == null || line.trim().isEmpty() || !line.startsWith("data:")) {
continue;
}
final String dataLine = line.substring(5).trim();
if ("[DONE]".equals(dataLine)) {
break;
}
final JsonObject data = JsonParser.parseString(dataLine).getAsJsonObject();
if (data.has("usage") && data.get("usage").isJsonObject()) {
final JsonObject usage = data.getAsJsonObject("usage");
usagePromptTokens = optLong(usage, "prompt_tokens");
usageCompletionTokens = optLong(usage, "completion_tokens");
usageTotalTokens = optLong(usage, "total_tokens");
final Map<String, Object> usagePayload = new LinkedHashMap<String, Object>();
usagePayload.put("promptTokens", usagePromptTokens);
usagePayload.put("completionTokens", usageCompletionTokens);
usagePayload.put("totalTokens", usageTotalTokens);
usagePayload.put("cacheHitTokens", optLong(usage, "prompt_cache_hit_tokens"));
usagePayload.put("cacheMissTokens", optLong(usage, "prompt_cache_miss_tokens"));
context.emitEvent("usage", usagePayload);
}
final JsonArray choices = data.getAsJsonArray("choices");
if (choices == null || choices.size() == 0) {
continue;
}
final JsonObject first = choices.get(0).getAsJsonObject();
if (first.has("message") && first.get("message").isJsonObject()) {
final String content = optString(first.getAsJsonObject("message"), "content");
if (content != null && !content.trim().isEmpty()) {
finalMessageContent = content.trim();
}
}
if (first.has("delta") && first.get("delta").isJsonObject()) {
final JsonObject delta = first.getAsJsonObject("delta");
final String reasoning = optString(delta, "reasoning_content");
if (reasoning != null && !reasoning.isEmpty()) {
reasoningDeltaCount++;
reasoningBuilder.append(reasoning);
context.emitEvent("reasoning_delta", buildTextPayload(reasoning));
if (reasoningDeltaCount % STREAM_PERSIST_INTERVAL == 0) {
context.updateAiOutput(reasoningBuilder.toString(), answerBuilder.toString());
}
}
final String answer = optString(delta, "content");
if (answer != null && !answer.isEmpty()) {
answerDeltaCount++;
answerBuilder.append(answer);
context.emitEvent("answer_delta", buildTextPayload(answer));
if (answerDeltaCount % STREAM_PERSIST_INTERVAL == 0) {
context.updateAiOutput(reasoningBuilder.toString(), answerBuilder.toString());
}
}
}
if (first.has("finish_reason") && !first.get("finish_reason").isJsonNull()) {
finishReason = first.get("finish_reason").getAsString();
}
}
if ("insufficient_system_resource".equalsIgnoreCase(finishReason)) {
throw new RetrySupport.RetryableException(providerContext.displayName + " 资源不足,请稍后重试");
}
String answer = answerBuilder.toString().trim();
if (answer.isEmpty() && finalMessageContent != null && !finalMessageContent.isEmpty()) {
answer = finalMessageContent;
}
if (answer.isEmpty()) {
throw new IllegalStateException(
providerContext.displayName + " 未返回有效 content 内容"
+ " | stage_model=" + model
+ " | api_model=" + model
+ " | finish_reason=" + finishReason
+ " | prompt_tokens=" + usagePromptTokens
+ " | completion_tokens=" + usageCompletionTokens
+ " | total_tokens=" + usageTotalTokens
);
}
context.updateAiOutput(reasoningBuilder.toString(), answer);
context.setAiStreamStatus("DONE");
LOGGER.info(
"{} stream deltas: model={}, reasoning={}, answer={}, finishReason={}, maxTokens={}",
providerContext.displayName,
model,
reasoningDeltaCount,
answerDeltaCount,
finishReason,
maxTokens
);
return new AiStreamResult(answer, finishReason);
}
private boolean isValidJsonObjectText(String text) {
if (text == null || text.trim().isEmpty()) {
return false;
@@ -1073,14 +1132,61 @@ public class AiWorkflowService {
}
}
private static final class DeepSeekStreamResult {
static final class AiProviderContext {
private final String provider;
private final String displayName;
private final String apiUrl;
private final String apiKey;
private final String stageOneModel;
private final String stageTwoModel;
private AiProviderContext(String provider,
String displayName,
String apiUrl,
String apiKey,
String stageOneModel,
String stageTwoModel) {
this.provider = provider == null ? "" : provider.trim();
this.displayName = displayName == null ? "" : displayName.trim();
this.apiUrl = apiUrl == null ? "" : apiUrl.trim();
this.apiKey = apiKey == null ? "" : apiKey.trim();
this.stageOneModel = stageOneModel == null ? "" : stageOneModel.trim();
this.stageTwoModel = stageTwoModel == null ? "" : stageTwoModel.trim();
}
String getProvider() {
return provider;
}
String getApiUrl() {
return apiUrl;
}
String getStageOneModel() {
return stageOneModel;
}
String getStageTwoModel() {
return stageTwoModel;
}
}
static final class AiStreamResult {
private final String answer;
private final String finishReason;
private DeepSeekStreamResult(String answer, String finishReason) {
private AiStreamResult(String answer, String finishReason) {
this.answer = answer == null ? "" : answer;
this.finishReason = finishReason == null ? "" : finishReason;
}
String getAnswer() {
return answer;
}
String getFinishReason() {
return finishReason;
}
}
private Map<String, Object> buildTextPayload(String text) {
@@ -8,18 +8,35 @@ import org.springframework.stereotype.Service;
@Service
public class SettingsService {
public static final String PROVIDER_DEEPSEEK = "deepseek";
public static final String PROVIDER_OPENAI_COMPATIBLE = "openai-compatible";
// 启动默认 API Key(仅作为本地默认值,可在设置页覆盖)
private static final String BOOTSTRAP_API_KEY = "sk-48c59012c93b43a08fecbaf3e74799e7";
private static final String DEFAULT_OPENAI_BASE_URL = "http://127.0.0.1:5001/v1";
private static final String DEFAULT_OPENAI_API_KEY = "sk-f8b3f43e1fdd4f50b287050f08a6c7ed";
private static final String DEFAULT_OPENAI_STAGE_ONE_MODEL = "deepseek-v4-flash";
private static final String DEFAULT_OPENAI_STAGE_TWO_MODEL = "deepseek-v4-pro";
private final OutputFileService outputFileService;
private final SvnPresetService svnPresetService;
private volatile String runtimeApiKey;
private volatile String runtimeProvider;
private volatile String runtimeOpenaiBaseUrl;
private volatile String runtimeOpenaiApiKey;
private volatile String runtimeOpenaiStageOneModel;
private volatile String runtimeOpenaiStageTwoModel;
private volatile String defaultSvnPresetId;
public SettingsService(OutputFileService outputFileService, SvnPresetService svnPresetService) {
this.outputFileService = outputFileService;
this.svnPresetService = svnPresetService;
this.runtimeApiKey = initStartupApiKey();
this.runtimeProvider = PROVIDER_DEEPSEEK;
this.runtimeOpenaiBaseUrl = DEFAULT_OPENAI_BASE_URL;
this.runtimeOpenaiApiKey = DEFAULT_OPENAI_API_KEY;
this.runtimeOpenaiStageOneModel = DEFAULT_OPENAI_STAGE_ONE_MODEL;
this.runtimeOpenaiStageTwoModel = DEFAULT_OPENAI_STAGE_TWO_MODEL;
this.defaultSvnPresetId = svnPresetService.configuredDefaultPresetId();
}
@@ -28,17 +45,43 @@ public class SettingsService {
final String envKey = System.getenv("DEEPSEEK_API_KEY");
final String activeKey = pickActiveKey(null);
result.put("provider", getProvider());
result.put("apiKeyConfigured", activeKey != null && !activeKey.trim().isEmpty());
result.put("apiKeySource", detectApiKeySource(envKey));
result.put("openaiBaseUrl", getOpenaiBaseUrl());
result.put("openaiApiKey", getOpenaiApiKey());
result.put("openaiApiKeyConfigured", isConfigured(getOpenaiApiKey()));
result.put("openaiStageOneModel", getOpenaiStageOneModel());
result.put("openaiStageTwoModel", getOpenaiStageTwoModel());
result.put("outputDir", outputFileService.getOutputRoot().toString());
result.put("defaultSvnPresetId", getDefaultSvnPresetId());
return result;
}
public void updateSettings(String apiKey, String outputDir, String newDefaultSvnPresetId) {
public void updateSettings(String apiKey,
String provider,
String openaiBaseUrl,
String openaiApiKey,
String openaiStageOneModel,
String openaiStageTwoModel,
String outputDir,
String newDefaultSvnPresetId) {
if (apiKey != null && !apiKey.trim().isEmpty()) {
this.runtimeApiKey = apiKey.trim();
}
this.runtimeProvider = normalizeProvider(provider);
if (openaiBaseUrl != null && !openaiBaseUrl.trim().isEmpty()) {
this.runtimeOpenaiBaseUrl = openaiBaseUrl.trim();
}
if (openaiApiKey != null && !openaiApiKey.trim().isEmpty()) {
this.runtimeOpenaiApiKey = openaiApiKey.trim();
}
if (openaiStageOneModel != null && !openaiStageOneModel.trim().isEmpty()) {
this.runtimeOpenaiStageOneModel = openaiStageOneModel.trim();
}
if (openaiStageTwoModel != null && !openaiStageTwoModel.trim().isEmpty()) {
this.runtimeOpenaiStageTwoModel = openaiStageTwoModel.trim();
}
if (outputDir != null && !outputDir.trim().isEmpty()) {
outputFileService.setOutputRoot(outputDir);
}
@@ -92,4 +135,44 @@ public class SettingsService {
}
return svnPresetService.firstPresetId();
}
public String getProvider() {
return normalizeProvider(runtimeProvider);
}
public String getOpenaiBaseUrl() {
return trimOrDefault(runtimeOpenaiBaseUrl, DEFAULT_OPENAI_BASE_URL);
}
public String getOpenaiApiKey() {
return trimOrDefault(runtimeOpenaiApiKey, DEFAULT_OPENAI_API_KEY);
}
public String getOpenaiStageOneModel() {
return trimOrDefault(runtimeOpenaiStageOneModel, DEFAULT_OPENAI_STAGE_ONE_MODEL);
}
public String getOpenaiStageTwoModel() {
return trimOrDefault(runtimeOpenaiStageTwoModel, DEFAULT_OPENAI_STAGE_TWO_MODEL);
}
private String normalizeProvider(String provider) {
if (PROVIDER_OPENAI_COMPATIBLE.equalsIgnoreCase(trim(provider))) {
return PROVIDER_OPENAI_COMPATIBLE;
}
return PROVIDER_DEEPSEEK;
}
private String trimOrDefault(String value, String defaultValue) {
final String trimmed = trim(value);
return trimmed.isEmpty() ? defaultValue : trimmed;
}
private boolean isConfigured(String value) {
return value != null && !value.trim().isEmpty();
}
private String trim(String value) {
return value == null ? "" : value.trim();
}
}
+39 -2
View File
@@ -63,6 +63,10 @@ function bindForms() {
const settingsForm = document.querySelector("#settings-form");
settingsForm.addEventListener("submit", onSaveSettings);
const settingsProvider = document.querySelector("#settings-provider");
if (settingsProvider) {
settingsProvider.addEventListener("change", () => updateSettingsProviderUI(settingsProvider.value));
}
const taskFilterBtn = document.querySelector("#btn-task-filter");
if (taskFilterBtn) {
@@ -870,6 +874,14 @@ function renderFileTable() {
async function loadSettings() {
try {
const data = await apiFetch("/api/settings");
document.querySelector("#settings-form [name='provider']").value = data.provider || "deepseek";
document.querySelector("#settings-form [name='apiKey']").value = "";
document.querySelector("#settings-form [name='openaiBaseUrl']").value = data.openaiBaseUrl || "";
document.querySelector("#settings-form [name='openaiApiKey']").value = data.openaiApiKey || "";
document.querySelector("#settings-form [name='openaiStageOneModel']").value =
data.openaiStageOneModel || "deepseek-v4-flash";
document.querySelector("#settings-form [name='openaiStageTwoModel']").value =
data.openaiStageTwoModel || "deepseek-v4-pro";
document.querySelector("#settings-form [name='outputDir']").value = data.outputDir || "";
state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
const settingsPreset = document.querySelector("#settings-default-preset");
@@ -877,7 +889,8 @@ async function loadSettings() {
settingsPreset.value = state.defaultPresetId;
}
applyPresetToSvnForm(state.defaultPresetId);
document.querySelector("#settings-state").textContent = `API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource}`;
updateSettingsProviderUI(data.provider || "deepseek");
renderSettingsState(data);
} catch (err) {
toast(err.message, true);
}
@@ -895,7 +908,8 @@ async function onSaveSettings(event) {
});
state.defaultPresetId = data.defaultSvnPresetId || state.defaultPresetId;
applyPresetToSvnForm(state.defaultPresetId);
document.querySelector("#settings-state").textContent = `API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource}`;
updateSettingsProviderUI(data.provider || "deepseek");
renderSettingsState(data);
toast("设置保存成功");
} catch (err) {
toast(err.message, true);
@@ -909,6 +923,29 @@ function readForm(form) {
return Object.fromEntries(data.entries());
}
function updateSettingsProviderUI(provider) {
const isOpenAiCompatible = provider === "openai-compatible";
const groupedSection = document.querySelector("#openai-settings-group");
if (groupedSection) {
groupedSection.hidden = !isOpenAiCompatible;
}
document.querySelectorAll("#openai-api-key-group, #openai-stage-one-group, #openai-stage-two-group").forEach((node) => {
node.hidden = !isOpenAiCompatible;
});
}
function renderSettingsState(data) {
const stateEl = document.querySelector("#settings-state");
if (!stateEl) {
return;
}
if ((data.provider || "deepseek") === "openai-compatible") {
stateEl.textContent = `当前提供商:OpenAI兼容 | Base URL${data.openaiBaseUrl || "(未配置)"} | API Key${data.openaiApiKeyConfigured ? "已配置" : "未配置"} | Stage1${data.openaiStageOneModel || "-"} | Stage2${data.openaiStageTwoModel || "-"}`;
return;
}
stateEl.textContent = `当前提供商:DeepSeek | API Key 状态:${data.apiKeyConfigured ? "已配置" : "未配置"}(来源:${data.apiKeySource}`;
}
function setLoading(button, loading) {
if (!button) {
return;
@@ -345,11 +345,45 @@
</div>
<div class="card-body">
<form id="settings-form" class="form-layout">
<div class="form-group form-group-full">
<label for="settings-provider" class="form-label">AI 提供商</label>
<select name="provider" id="settings-provider" class="form-select">
<option value="deepseek">DeepSeek</option>
<option value="openai-compatible">OpenAI兼容</option>
</select>
</div>
<div class="form-group form-group-full">
<label for="api-key-input" class="form-label">DeepSeek API Key</label>
<input type="password" name="apiKey" id="api-key-input" class="form-input" placeholder="设置后将保存在当前进程内存">
</div>
<div class="form-group form-group-full" id="openai-settings-group" hidden>
<label for="openai-base-url" class="form-label">OpenAI兼容 Base URL</label>
<input type="text" name="openaiBaseUrl" id="openai-base-url" class="form-input" placeholder="例如 http://127.0.0.1:5001/v1">
</div>
<div class="form-group form-group-full" id="openai-api-key-group" hidden>
<label for="openai-api-key" class="form-label">OpenAI兼容 API Key</label>
<input type="password" name="openaiApiKey" id="openai-api-key" class="form-input" placeholder="设置后将保存在当前进程内存">
</div>
<div class="form-group" id="openai-stage-one-group" hidden>
<label for="openai-stage-one-model" class="form-label">第一阶段模型</label>
<select name="openaiStageOneModel" id="openai-stage-one-model" class="form-select">
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
</select>
</div>
<div class="form-group" id="openai-stage-two-group" hidden>
<label for="openai-stage-two-model" class="form-label">第二阶段模型</label>
<select name="openaiStageTwoModel" id="openai-stage-two-model" class="form-select">
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
</select>
</div>
<div class="form-group form-group-full">
<label for="default-preset" class="form-label">默认 SVN 项目</label>
<select name="defaultSvnPresetId" id="default-preset" class="form-select"></select>
+27 -1
View File
@@ -184,9 +184,35 @@
<article class="card form-card">
<h3>系统设置</h3>
<form id="settings-form" class="form-grid">
<label class="span-2">AI 提供商
<select name="provider" id="settings-provider">
<option value="deepseek">DeepSeek</option>
<option value="openai-compatible">OpenAI兼容</option>
</select>
</label>
<label class="span-2">DeepSeek API Key
<input type="password" name="apiKey" placeholder="设置后将保存在当前进程内存">
</label>
<div class="span-2" id="openai-settings-group" hidden>
<label class="span-2">OpenAI兼容 Base URL
<input type="text" name="openaiBaseUrl" placeholder="例如 http://127.0.0.1:5001/v1">
</label>
<label class="span-2">OpenAI兼容 API Key
<input type="password" name="openaiApiKey" placeholder="设置后将保存在当前进程内存">
</label>
<label>第一阶段模型
<select name="openaiStageOneModel">
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
</select>
</label>
<label>第二阶段模型
<select name="openaiStageTwoModel">
<option value="deepseek-v4-pro">deepseek-v4-pro</option>
<option value="deepseek-v4-flash">deepseek-v4-flash</option>
</select>
</label>
</div>
<label class="span-2">默认 SVN 项目
<select name="defaultSvnPresetId" id="settings-default-preset"></select>
</label>
@@ -205,6 +231,6 @@
</main>
</div>
<script src="/app.js?v=20260407_1811" defer></script>
<script src="/app.js?v=20260429_1808" defer></script>
</body>
</html>
@@ -0,0 +1,150 @@
package com.svnlog.web.service;
import java.nio.file.Path;
import java.time.Instant;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import com.svnlog.web.model.TaskInfo;
import com.svnlog.web.model.TaskStatus;
import okio.Buffer;
class AiWorkflowServiceTest {
@TempDir
Path tempDir;
@Test
void shouldResolveDeepSeekProviderByDefault() {
final AiWorkflowService service = new AiWorkflowService(
buildOutputFileService(),
new SettingsService(buildOutputFileService(), new SvnPresetService()),
new AiInputValidator()
);
final AiWorkflowService.AiProviderContext context = service.resolveProviderContext(null);
Assertions.assertEquals(SettingsService.PROVIDER_DEEPSEEK, context.getProvider());
Assertions.assertEquals("deepseek-chat", context.getStageOneModel());
Assertions.assertEquals("deepseek-reasoner", context.getStageTwoModel());
}
@Test
void shouldResolveOpenAiCompatibleModelsAndUrl() {
final OutputFileService outputFileService = buildOutputFileService();
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
settingsService.updateSettings(
null,
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
"http://127.0.0.1:5001/v1/",
"sk-openai-test",
"deepseek-v4-flash",
"deepseek-v4-pro",
null,
null
);
final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator());
final AiWorkflowService.AiProviderContext context = service.resolveProviderContext(null);
Assertions.assertEquals(SettingsService.PROVIDER_OPENAI_COMPATIBLE, context.getProvider());
Assertions.assertEquals("http://127.0.0.1:5001/v1/chat/completions", context.getApiUrl());
Assertions.assertEquals("deepseek-v4-flash", context.getStageOneModel());
Assertions.assertEquals("deepseek-v4-pro", context.getStageTwoModel());
}
@Test
void shouldFailFastWhenOpenAiCompatibleBaseUrlMissing() {
final AiWorkflowService service = new AiWorkflowService(
buildOutputFileService(),
new StubSettingsService(buildOutputFileService(), " ", "sk-openai-test"),
new AiInputValidator()
);
final IllegalStateException error = Assertions.assertThrows(
IllegalStateException.class,
() -> service.resolveProviderContext(null)
);
Assertions.assertTrue(error.getMessage().contains("OpenAI兼容 Base URL"));
}
@Test
void shouldParseCompatibleStreamWhenOnlyContentIsReturned() throws Exception {
final OutputFileService outputFileService = buildOutputFileService();
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
settingsService.updateSettings(
null,
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
"http://127.0.0.1:5001/v1",
"sk-openai-test",
"deepseek-v4-flash",
"deepseek-v4-pro",
null,
null
);
final AiWorkflowService service = new AiWorkflowService(outputFileService, settingsService, new AiInputValidator());
final AiWorkflowService.AiProviderContext providerContext = service.resolveProviderContext(null);
final TaskContext taskContext = new TaskContext(buildTaskInfo(), null, null);
final Buffer buffer = new Buffer()
.writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"{\\\"items\\\":[\"}}]}\n")
.writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"{\\\"summary\\\":\\\"done\\\"}\"}}]}\n")
.writeUtf8("data: {\"choices\":[{\"delta\":{\"content\":\"]}\"},\"finish_reason\":\"stop\"}]}\n")
.writeUtf8("data: [DONE]\n");
final AiWorkflowService.AiStreamResult result = service.readStreamingResponse(
buffer,
taskContext,
providerContext,
"deepseek-v4-flash",
8000
);
Assertions.assertEquals("{\"items\":[{\"summary\":\"done\"}]}", result.getAnswer());
Assertions.assertEquals("stop", result.getFinishReason());
}
private OutputFileService buildOutputFileService() {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
return outputFileService;
}
private TaskInfo buildTaskInfo() {
final TaskInfo taskInfo = new TaskInfo();
taskInfo.setTaskId("task-1");
taskInfo.setStatus(TaskStatus.RUNNING);
taskInfo.setCreatedAt(Instant.now());
taskInfo.setUpdatedAt(Instant.now());
return taskInfo;
}
private static final class StubSettingsService extends SettingsService {
private final String openaiBaseUrl;
private final String openaiApiKey;
private StubSettingsService(OutputFileService outputFileService, String openaiBaseUrl, String openaiApiKey) {
super(outputFileService, new SvnPresetService());
this.openaiBaseUrl = openaiBaseUrl;
this.openaiApiKey = openaiApiKey;
}
@Override
public String getProvider() {
return SettingsService.PROVIDER_OPENAI_COMPATIBLE;
}
@Override
public String getOpenaiBaseUrl() {
return openaiBaseUrl;
}
@Override
public String getOpenaiApiKey() {
return openaiApiKey;
}
}
}
@@ -1,36 +1,40 @@
package com.svnlog.web.service;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.junit.jupiter.api.io.TempDir;
public class HealthServiceTest {
class HealthServiceTest {
@TempDir
Path tempDir;
private TaskService taskService;
@AfterEach
void tearDown() {
if (taskService != null) {
taskService.destroy();
}
}
@Test
public void shouldReturnDetailedHealthWhenDependenciesAvailable() throws Exception {
OutputFileService outputFileService = Mockito.mock(OutputFileService.class);
SettingsService settingsService = Mockito.mock(SettingsService.class);
TaskService taskService = Mockito.mock(TaskService.class);
void shouldReturnDetailedHealthAfterSettingsExpansion() throws Exception {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
taskService = new TaskService(new TaskPersistenceService(), outputFileService);
final HealthService healthService = new HealthService(outputFileService, settingsService, taskService);
Path outputDir = Files.createTempDirectory("health-service-test");
Mockito.when(outputFileService.getOutputRoot()).thenReturn(outputDir);
Map<String, Object> settings = new HashMap<String, Object>();
settings.put("apiKeyConfigured", true);
Mockito.when(settingsService.getSettings()).thenReturn(settings);
Mockito.when(taskService.getTasks()).thenReturn(new java.util.ArrayList<>());
HealthService healthService = new HealthService(outputFileService, settingsService, taskService);
Map<String, Object> details = healthService.detailedHealth();
final Map<String, Object> details = healthService.detailedHealth();
Assertions.assertEquals("ok", details.get("status"));
Assertions.assertEquals(true, details.get("outputDirWritable"));
Assertions.assertEquals(true, details.get("apiKeyConfigured"));
Assertions.assertEquals(0, details.get("taskTotal"));
Assertions.assertTrue(Boolean.TRUE.equals(details.get("outputDirWritable")));
Assertions.assertTrue(details.containsKey("apiKeyConfigured"));
Assertions.assertEquals(Integer.valueOf(0), details.get("taskTotal"));
}
}
@@ -0,0 +1,59 @@
package com.svnlog.web.service;
import java.nio.file.Path;
import java.util.Map;
import java.nio.file.Paths;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SettingsServiceTest {
@TempDir
Path tempDir;
@Test
void shouldReturnDefaultProviderAndOpenAiDefaults() throws Exception {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
final Map<String, Object> settings = settingsService.getSettings();
Assertions.assertEquals(SettingsService.PROVIDER_DEEPSEEK, settings.get("provider"));
Assertions.assertEquals("http://127.0.0.1:5001/v1", settings.get("openaiBaseUrl"));
Assertions.assertEquals("sk-f8b3f43e1fdd4f50b287050f08a6c7ed", settings.get("openaiApiKey"));
Assertions.assertEquals("deepseek-v4-flash", settings.get("openaiStageOneModel"));
Assertions.assertEquals("deepseek-v4-pro", settings.get("openaiStageTwoModel"));
}
@Test
void shouldUpdateProviderAndOpenAiSettings() throws Exception {
final OutputFileService outputFileService = new OutputFileService();
outputFileService.setOutputRoot(tempDir.resolve("outputs").toString());
final SettingsService settingsService = new SettingsService(outputFileService, new SvnPresetService());
final String newOutputDir = tempDir.resolve("custom-output").toString();
settingsService.updateSettings(
"sk-deepseek-runtime",
SettingsService.PROVIDER_OPENAI_COMPATIBLE,
"http://localhost:5001/v1/",
"sk-openai-runtime",
"deepseek-v4-flash",
"deepseek-v4-pro",
newOutputDir,
"preset-2"
);
final Map<String, Object> settings = settingsService.getSettings();
Assertions.assertEquals(SettingsService.PROVIDER_OPENAI_COMPATIBLE, settings.get("provider"));
Assertions.assertEquals("http://localhost:5001/v1/", settings.get("openaiBaseUrl"));
Assertions.assertEquals("sk-openai-runtime", settings.get("openaiApiKey"));
Assertions.assertEquals("deepseek-v4-flash", settings.get("openaiStageOneModel"));
Assertions.assertEquals("deepseek-v4-pro", settings.get("openaiStageTwoModel"));
Assertions.assertEquals("preset-2", settings.get("defaultSvnPresetId"));
Assertions.assertEquals(Paths.get(newOutputDir).toAbsolutePath().normalize().toString(), settings.get("outputDir"));
}
}