diff --git a/README.md b/README.md
index 3515d701..a8462144 100644
--- a/README.md
+++ b/README.md
@@ -83,6 +83,25 @@ Web 工具默认关闭。配置 `lypi.web.enabled=true` 后,运行时会注册
OpenAI 兼容适配支持 Responses、Chat Completions、SSE、WebSocket 和 fallback request style。上层收到的是项目内部的 `AssistantStreamEvent`,不需要直接处理供应商原始事件。模型描述中的 context window、最大输出 token、thinking 支持和图片输入支持会影响请求构建与上下文预算。
+启用 `model-discovery` 的 OpenAI 兼容 Provider 会在应用启动时按配置顺序拉取模型列表;第一个非空结果成为该 Provider 的权威模型集合。远端显式能力字段覆盖 `lypi.ai.model-discovery.defaults`,用户配置的静态同名 `models[]` 再以完整模型描述覆盖远端结果;远端没有返回的静态 model ID 不会进入目录。缺失能力字段默认使用 `context-window=256000`、`max-output-tokens=8192`、`supports-thinking=true` 和 `supports-image-input=true`。所有候选端点都没有返回有效模型时,应用会以不含凭据的端点诊断终止启动。
+
+TUI 输入无参数 `/model` 会打开启动期模型快照,候选项统一显示为 `provider/model`;使用上下方向键移动,Enter 切换,Esc 取消。选择结果仍写入会话模型变更条目,恢复会话后继续生效。
+
+TUI 的 `/login` 可注册 OpenAI-compatible Provider,交互顺序为:
+
+```text
+/login
+1. Channel name
+2. Base URL
+3. Auth key(掩码显示)
+```
+
+渠道名必须匹配 `[a-z0-9][a-z0-9_-]{0,63}`,成功后模型以 `/` 出现在 `/model`。同名再次登录表示替换该渠道;URL、凭据、模型发现和持久化任一步失败时,已有运行时渠道保持不变。
+
+登录固定使用 OpenAI-compatible Chat Completions over SSE。系统会依次探测 `/models` 和 `/model`,仅在至少发现一个可用模型后才保存并注册 Provider;成功后不会自动切换当前会话模型。日常登录只发现模型目录,不逐模型发送收费能力探针;仓库中的显式真实 E2E 会验证 HIGH thinking、工具续轮和图片 Chat Completions。
+
+登录数据仅写入受管文件 `/.ly-pi/login-providers.properties`,不会改写用户维护的 `/.ly-pi/application.yml`,也不缓存发现到的模型列表。应用重启时会重新发现模型,并重新应用当前全局默认值和静态同名完整描述覆盖。
+
Anthropic 适配负责 Messages 请求、SSE 事件归一化、tool call/result 映射和 usage 合并。当前版本不启用 Anthropic extended thinking:Anthropic 模型的 `supports-thinking` 应保持 `false`,作为默认模型时还需把 `lypi.runtime.thinking-level` 设为 `off`。
### 资源与记忆
diff --git a/lypi-ai/src/main/java/cn/lypi/ai/DefaultModelRegistry.java b/lypi-ai/src/main/java/cn/lypi/ai/DefaultModelRegistry.java
index 499946de..aad0a567 100644
--- a/lypi-ai/src/main/java/cn/lypi/ai/DefaultModelRegistry.java
+++ b/lypi-ai/src/main/java/cn/lypi/ai/DefaultModelRegistry.java
@@ -2,28 +2,54 @@
import cn.lypi.contracts.model.ModelDescriptor;
import cn.lypi.contracts.model.ModelSelection;
+import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
-public final class DefaultModelRegistry implements ModelRegistry {
- private final List descriptors;
+public final class DefaultModelRegistry implements RuntimeModelRegistry {
+ private final AtomicReference> descriptors;
public DefaultModelRegistry(List descriptors) {
- this.descriptors = List.copyOf(Objects.requireNonNull(descriptors, "descriptors"));
+ this.descriptors = new AtomicReference<>(immutableDescriptors(descriptors));
}
@Override
public List list() {
- return descriptors;
+ return descriptors.get();
}
@Override
public Optional find(ModelSelection selection) {
Objects.requireNonNull(selection, "selection");
- return descriptors.stream()
+ List current = descriptors.get();
+ return current.stream()
.filter(descriptor -> descriptor.provider().equals(selection.provider()))
.filter(descriptor -> descriptor.modelId().equals(selection.modelId()))
.findFirst();
}
+
+ @Override
+ public void replaceProvider(String provider, List replacement) {
+ String requiredProvider = Objects.requireNonNull(provider, "provider");
+ List requiredReplacement = immutableDescriptors(replacement);
+ for (ModelDescriptor descriptor : requiredReplacement) {
+ if (!requiredProvider.equals(descriptor.provider())) {
+ throw new IllegalArgumentException("Replacement descriptor provider must match provider.");
+ }
+ }
+ descriptors.updateAndGet(current -> {
+ List next = new ArrayList<>(current.size() + requiredReplacement.size());
+ current.stream()
+ .filter(descriptor -> !requiredProvider.equals(descriptor.provider()))
+ .forEach(next::add);
+ next.addAll(requiredReplacement);
+ return List.copyOf(next);
+ });
+ }
+
+ private static List immutableDescriptors(List descriptors) {
+ return List.copyOf(Objects.requireNonNull(descriptors, "descriptors"));
+ }
}
diff --git a/lypi-ai/src/main/java/cn/lypi/ai/ProviderAdapterApiProvider.java b/lypi-ai/src/main/java/cn/lypi/ai/ProviderAdapterApiProvider.java
index dcfb1b9b..923f6535 100644
--- a/lypi-ai/src/main/java/cn/lypi/ai/ProviderAdapterApiProvider.java
+++ b/lypi-ai/src/main/java/cn/lypi/ai/ProviderAdapterApiProvider.java
@@ -10,20 +10,19 @@
import cn.lypi.contracts.runtime.AiProviderRuntimePort;
import cn.lypi.contracts.runtime.AiStreamOptions;
import cn.lypi.contracts.tool.ToolRegistrySnapshot;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
-import java.util.function.Function;
-import java.util.stream.Collectors;
+import java.util.concurrent.atomic.AtomicReference;
public final class ProviderAdapterApiProvider implements ApiProvider {
private final ApiStyle apiStyle;
- private final Map adapters;
+ private final AtomicReference
+ */
+public class LoginProviderPropertiesStore {
+ private static final String PROVIDERS_PREFIX = "lypi.ai.providers.";
+ private static final List DISCOVERY_PATHS = List.of("/models", "/model");
+ private static final Set PRIVATE_FILE_PERMISSIONS = EnumSet.of(
+ PosixFilePermission.OWNER_READ,
+ PosixFilePermission.OWNER_WRITE
+ );
+
+ private final Path file;
+
+ public LoginProviderPropertiesStore(Path userHome) {
+ this.file = Objects.requireNonNull(userHome, "userHome")
+ .resolve(".ly-pi")
+ .resolve("login-providers.properties");
+ }
+
+ public void save(String provider, URI baseUrl, String authKey) throws IOException {
+ String requiredProvider = requireProvider(provider);
+ URI requiredBaseUrl = Objects.requireNonNull(baseUrl, "baseUrl");
+ String requiredAuthKey = Objects.requireNonNull(authKey, "authKey");
+ Path directory = file.getParent();
+ Path temporary = null;
+ boolean moved = false;
+ try {
+ Files.createDirectories(directory);
+ Properties properties = readProperties();
+ String prefix = PROVIDERS_PREFIX + requiredProvider + ".";
+ removeProviderProperties(properties, prefix);
+ writeProviderProperties(properties, prefix, requiredBaseUrl, requiredAuthKey);
+
+ temporary = Files.createTempFile(directory, ".login-providers-", ".tmp");
+ setPrivatePermissionsIfSupported(temporary);
+ writeProperties(temporary, properties);
+ moveReplacing(temporary, file);
+ moved = true;
+ setPrivatePermissionsIfSupported(file);
+ } catch (IOException | RuntimeException ignored) {
+ throw persistenceFailure();
+ } finally {
+ if (!moved && temporary != null) {
+ deleteQuietly(temporary);
+ }
+ }
+ }
+
+ private Properties readProperties() throws IOException {
+ Properties properties = new Properties();
+ if (!Files.exists(file)) {
+ return properties;
+ }
+ try (java.io.InputStream input = Files.newInputStream(file)) {
+ properties.load(input);
+ }
+ return properties;
+ }
+
+ private static void removeProviderProperties(Properties properties, String prefix) {
+ for (String propertyName : List.copyOf(properties.stringPropertyNames())) {
+ if (propertyName.startsWith(prefix)) {
+ properties.remove(propertyName);
+ }
+ }
+ }
+
+ private static void writeProviderProperties(
+ Properties properties,
+ String prefix,
+ URI baseUrl,
+ String authKey
+ ) {
+ properties.setProperty(prefix + "enabled", "true");
+ properties.setProperty(prefix + "api-style", "openai_compatible");
+ properties.setProperty(prefix + "base-url", baseUrl.toString());
+ properties.setProperty(prefix + "api-key", authKey);
+ properties.setProperty(prefix + "request-style", "chat_completions");
+ properties.setProperty(prefix + "fallback-request-style", "chat_completions");
+ properties.setProperty(prefix + "transport", "sse");
+ properties.setProperty(prefix + "model-discovery.enabled", "true");
+ for (int index = 0; index < DISCOVERY_PATHS.size(); index++) {
+ properties.setProperty(prefix + "model-discovery.paths[" + index + "]", DISCOVERY_PATHS.get(index));
+ }
+ properties.setProperty(prefix + "compat.requires-reasoning-content-on-assistant-messages", "true");
+ }
+
+ private static void writeProperties(Path temporary, Properties properties) throws IOException {
+ try (OutputStream output = Files.newOutputStream(temporary)) {
+ properties.store(output, null);
+ }
+ }
+
+ private static void moveReplacing(Path temporary, Path target) throws IOException {
+ try {
+ Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ } catch (AtomicMoveNotSupportedException error) {
+ Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ private static void setPrivatePermissionsIfSupported(Path path) throws IOException {
+ if (Files.getFileAttributeView(path, PosixFileAttributeView.class) != null) {
+ Files.setPosixFilePermissions(path, PRIVATE_FILE_PERMISSIONS);
+ }
+ }
+
+ private static void deleteQuietly(Path path) {
+ try {
+ Files.deleteIfExists(path);
+ } catch (IOException ignored) {
+ // The temporary path name is random and contains no provider credentials.
+ }
+ }
+
+ private static String requireProvider(String provider) {
+ if (provider == null || provider.isBlank()) {
+ throw new IllegalArgumentException("provider is required");
+ }
+ return provider;
+ }
+
+ private static IOException persistenceFailure() {
+ return new IOException("Provider login properties could not be saved.");
+ }
+}
diff --git a/lypi-boot/src/main/java/cn/lypi/boot/ai/LyPiAiAutoConfiguration.java b/lypi-boot/src/main/java/cn/lypi/boot/ai/LyPiAiAutoConfiguration.java
index 637ecfc2..a855a305 100644
--- a/lypi-boot/src/main/java/cn/lypi/boot/ai/LyPiAiAutoConfiguration.java
+++ b/lypi-boot/src/main/java/cn/lypi/boot/ai/LyPiAiAutoConfiguration.java
@@ -8,9 +8,11 @@
import cn.lypi.ai.ModelRegistry;
import cn.lypi.ai.ProviderAdapter;
import cn.lypi.ai.ProviderAdapterApiProvider;
+import cn.lypi.ai.RuntimeModelRegistry;
import cn.lypi.ai.model.BuiltinModelDescriptorSource;
import cn.lypi.ai.model.CompatSanitizer;
import cn.lypi.ai.model.CompositeModelDescriptorSource;
+import cn.lypi.ai.model.DiscoveredModelDefaults;
import cn.lypi.ai.model.ModelDescriptorSource;
import cn.lypi.ai.model.RemoteModelDescriptorSource;
import cn.lypi.ai.model.RemoteModelDiscoveryClient;
@@ -33,14 +35,19 @@
import cn.lypi.contracts.model.ApiStyle;
import cn.lypi.contracts.model.CostProfile;
import cn.lypi.contracts.model.ModelDescriptor;
+import cn.lypi.contracts.runtime.ProviderLoginPort;
import java.math.BigDecimal;
+import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@@ -55,8 +62,8 @@ public class LyPiAiAutoConfiguration {
private static final Duration BUILTIN_OPENAI_TIMEOUT = Duration.ofSeconds(30);
@Bean
- @ConditionalOnMissingBean
- public ModelRegistry modelRegistry(LyPiAiProperties properties, RemoteModelDiscoveryClient discoveryClient) {
+ @ConditionalOnMissingBean(ModelRegistry.class)
+ public RuntimeModelRegistry modelRegistry(LyPiAiProperties properties, RemoteModelDiscoveryClient discoveryClient) {
return new DefaultModelRegistry(modelDescriptorSource(properties, discoveryClient).list());
}
@@ -72,22 +79,25 @@ public ModelPort modelPort(
@Bean
@ConditionalOnMissingBean
public ApiProviderRegistry apiProviderRegistry(
- @Qualifier("openAiCompatibleProviderAdapters") List openAiAdapters,
+ @Qualifier("openAiCompatibleApiProvider") ProviderAdapterApiProvider openAiCompatibleApiProvider,
@Qualifier("anthropicProviderAdapters") List anthropicAdapters
) {
List providers = new ArrayList<>();
- if (!openAiAdapters.isEmpty()) {
- providers.add(new ProviderAdapterApiProvider(ApiStyle.OPENAI_COMPATIBLE, openAiAdapters));
- }
+ providers.add(openAiCompatibleApiProvider);
if (!anthropicAdapters.isEmpty()) {
providers.add(new ProviderAdapterApiProvider(ApiStyle.ANTHROPIC, anthropicAdapters));
}
- if (providers.isEmpty()) {
- return new DefaultApiProviderRegistry(List.of());
- }
return new DefaultApiProviderRegistry(providers);
}
+ @Bean(name = "openAiCompatibleApiProvider")
+ @ConditionalOnMissingBean(name = "openAiCompatibleApiProvider")
+ public ProviderAdapterApiProvider openAiCompatibleApiProvider(
+ @Qualifier("openAiCompatibleProviderAdapters") List openAiAdapters
+ ) {
+ return new ProviderAdapterApiProvider(ApiStyle.OPENAI_COMPATIBLE, openAiAdapters);
+ }
+
@Bean
@ConditionalOnMissingBean(name = "openAiCompatibleProviderAdapters")
public List openAiCompatibleProviderAdapters(LyPiAiProperties properties) {
@@ -106,6 +116,35 @@ public RemoteModelDiscoveryClient remoteModelDiscoveryClient() {
return new RemoteModelDiscoveryClient();
}
+ @Bean
+ @ConditionalOnMissingBean
+ public LoginProviderPropertiesStore loginProviderPropertiesStore() {
+ return new LoginProviderPropertiesStore(Path.of(System.getProperty("user.home", ".")));
+ }
+
+ @Bean
+ @ConditionalOnMissingBean(ProviderLoginPort.class)
+ public ProviderLoginPort providerLoginPort(
+ LyPiAiProperties properties,
+ RemoteModelDiscoveryClient discoveryClient,
+ ObjectProvider modelRegistry,
+ @Qualifier("openAiCompatibleApiProvider") ObjectProvider openAiDispatcher,
+ LoginProviderPropertiesStore propertiesStore
+ ) {
+ RuntimeModelRegistry runtimeModelRegistry = modelRegistry.getIfAvailable();
+ ProviderAdapterApiProvider dispatcher = openAiDispatcher.getIfAvailable();
+ if (runtimeModelRegistry == null || dispatcher == null) {
+ return ProviderLoginPort.unavailable();
+ }
+ return new OpenAiCompatibleProviderLoginService(
+ discoveryClient,
+ runtimeModelRegistry,
+ dispatcher,
+ propertiesStore,
+ descriptorDefaults(properties)
+ );
+ }
+
@Bean
@ConditionalOnMissingBean
public CompactionSummarizer compactionSummarizer(ModelPort modelPort, LyPiAiProperties properties) {
@@ -118,11 +157,49 @@ public CompactionSummarizer compactionSummarizer(ModelPort modelPort, LyPiAiProp
}
private ModelDescriptorSource modelDescriptorSource(LyPiAiProperties properties, RemoteModelDiscoveryClient discoveryClient) {
- List sources = new ArrayList<>();
- sources.add(new StaticModelDescriptorSource(builtinModelDescriptors(properties)));
- sources.add(new StaticModelDescriptorSource(remoteModelDescriptors(properties, discoveryClient)));
- sources.add(new StaticModelDescriptorSource(modelDescriptors(properties)));
- return new CompositeModelDescriptorSource(sources);
+ DiscoveredModelDefaults defaults = descriptorDefaults(properties);
+ List remote = remoteModelDescriptors(properties, discoveryClient, defaults);
+ Set discovered = remote.stream()
+ .map(model -> new ModelKey(model.provider(), model.modelId()))
+ .collect(Collectors.toUnmodifiableSet());
+ List builtin = authoritativeLocalDescriptors(
+ properties,
+ builtinModelDescriptors(properties),
+ discovered
+ );
+ List configured = authoritativeLocalDescriptors(
+ properties,
+ modelDescriptors(properties),
+ discovered
+ );
+ return new CompositeModelDescriptorSource(List.of(
+ new StaticModelDescriptorSource(remote),
+ new StaticModelDescriptorSource(builtin),
+ new StaticModelDescriptorSource(configured)
+ ));
+ }
+
+ private List authoritativeLocalDescriptors(
+ LyPiAiProperties properties,
+ List local,
+ Set discovered
+ ) {
+ Map providers = effectiveProviders(properties);
+ return local.stream()
+ .filter(descriptor -> {
+ ProviderProperties provider = providers.get(descriptor.provider());
+ return !usesRemoteModelDiscovery(provider)
+ || discovered.contains(new ModelKey(descriptor.provider(), descriptor.modelId()));
+ })
+ .toList();
+ }
+
+ private boolean usesRemoteModelDiscovery(ProviderProperties provider) {
+ return provider != null
+ && provider.isEnabled()
+ && provider.getBaseUrl() != null
+ && valueOrDefault(provider.getApiStyle(), ApiStyle.OPENAI_COMPATIBLE) == ApiStyle.OPENAI_COMPATIBLE
+ && provider.getModelDiscovery().isEnabled();
}
private List builtinModelDescriptors(LyPiAiProperties properties) {
@@ -156,7 +233,11 @@ private List modelDescriptors(LyPiAiProperties properties) {
return descriptors;
}
- private List remoteModelDescriptors(LyPiAiProperties properties, RemoteModelDiscoveryClient discoveryClient) {
+ private List remoteModelDescriptors(
+ LyPiAiProperties properties,
+ RemoteModelDiscoveryClient discoveryClient,
+ DiscoveredModelDefaults defaults
+ ) {
List descriptors = new ArrayList<>();
effectiveProviders(properties).forEach((providerName, provider) -> {
if (!provider.isEnabled() || provider.getBaseUrl() == null || !provider.getModelDiscovery().isEnabled()) {
@@ -165,7 +246,6 @@ private List remoteModelDescriptors(LyPiAiProperties properties
if (valueOrDefault(provider.getApiStyle(), ApiStyle.OPENAI_COMPATIBLE) != ApiStyle.OPENAI_COMPATIBLE) {
return;
}
- RemoteModelDescriptorSource.DescriptorDefaults defaults = descriptorDefaults(provider);
descriptors.addAll(new RemoteModelDescriptorSource(
true,
providerName,
@@ -181,19 +261,18 @@ private List remoteModelDescriptors(LyPiAiProperties properties
return descriptors;
}
- private RemoteModelDescriptorSource.DescriptorDefaults descriptorDefaults(ProviderProperties provider) {
- ModelProperties firstModel = provider.getModels().isEmpty() ? new ModelProperties() : provider.getModels().getFirst();
- return new RemoteModelDescriptorSource.DescriptorDefaults(
- firstModel.getContextWindow(),
- firstModel.getMaxOutputTokens(),
- firstModel.isSupportsThinking(),
- firstModel.isSupportsImageInput(),
- new CostProfile(
- valueOrDefault(firstModel.getInputTokenCost(), BigDecimal.ZERO),
- valueOrDefault(firstModel.getOutputTokenCost(), BigDecimal.ZERO),
- valueOrDefault(firstModel.getCurrency(), "USD")
- ),
- sanitizedCompat(provider.getCompat(), firstModel.getCompat())
+ private DiscoveredModelDefaults descriptorDefaults(LyPiAiProperties properties) {
+ LyPiAiProperties.ModelDefaultsProperties defaults = properties.getModelDiscovery().getDefaults();
+ if (defaults.getContextWindow() <= 0 || defaults.getMaxOutputTokens() <= 0) {
+ throw new IllegalArgumentException("Model discovery default token limits must be positive.");
+ }
+ return new DiscoveredModelDefaults(
+ defaults.getContextWindow(),
+ defaults.getMaxOutputTokens(),
+ defaults.isSupportsThinking(),
+ defaults.isSupportsImageInput(),
+ new CostProfile(BigDecimal.ZERO, BigDecimal.ZERO, "USD"),
+ Map.of()
);
}
@@ -403,4 +482,7 @@ private ModelDescriptor withProviderOverrides(ModelDescriptor descriptor, Provid
private static T valueOrDefault(T value, T defaultValue) {
return value == null ? defaultValue : value;
}
+
+ private record ModelKey(String provider, String modelId) {
+ }
}
diff --git a/lypi-boot/src/main/java/cn/lypi/boot/ai/LyPiAiProperties.java b/lypi-boot/src/main/java/cn/lypi/boot/ai/LyPiAiProperties.java
index f0fdc7e8..89d626ac 100644
--- a/lypi-boot/src/main/java/cn/lypi/boot/ai/LyPiAiProperties.java
+++ b/lypi-boot/src/main/java/cn/lypi/boot/ai/LyPiAiProperties.java
@@ -2,6 +2,7 @@
import cn.lypi.ai.provider.RequestStyle;
import cn.lypi.ai.provider.TransportMode;
+import cn.lypi.ai.model.DiscoveredModelDefaults;
import cn.lypi.agent.compact.CompactionSummaryFallbackPolicy;
import cn.lypi.contracts.model.ApiStyle;
import java.math.BigDecimal;
@@ -17,6 +18,7 @@
public class LyPiAiProperties {
private String defaultProvider;
private String defaultModel;
+ private GlobalModelDiscoveryProperties modelDiscovery = new GlobalModelDiscoveryProperties();
private Map providers = new LinkedHashMap<>();
private CompactionSummaryProperties compactionSummary = new CompactionSummaryProperties();
@@ -36,6 +38,14 @@ public void setDefaultModel(String defaultModel) {
this.defaultModel = defaultModel;
}
+ public GlobalModelDiscoveryProperties getModelDiscovery() {
+ return modelDiscovery;
+ }
+
+ public void setModelDiscovery(GlobalModelDiscoveryProperties modelDiscovery) {
+ this.modelDiscovery = modelDiscovery == null ? new GlobalModelDiscoveryProperties() : modelDiscovery;
+ }
+
public Map getProviders() {
return providers;
}
@@ -66,6 +76,57 @@ public void setFallbackPolicy(CompactionSummaryFallbackPolicy fallbackPolicy) {
}
}
+ public static class GlobalModelDiscoveryProperties {
+ private ModelDefaultsProperties defaults = new ModelDefaultsProperties();
+
+ public ModelDefaultsProperties getDefaults() {
+ return defaults;
+ }
+
+ public void setDefaults(ModelDefaultsProperties defaults) {
+ this.defaults = defaults == null ? new ModelDefaultsProperties() : defaults;
+ }
+ }
+
+ public static class ModelDefaultsProperties {
+ private int contextWindow = DiscoveredModelDefaults.DEFAULT_CONTEXT_WINDOW;
+ private int maxOutputTokens = DiscoveredModelDefaults.DEFAULT_MAX_OUTPUT_TOKENS;
+ private boolean supportsThinking = DiscoveredModelDefaults.DEFAULT_SUPPORTS_THINKING;
+ private boolean supportsImageInput = DiscoveredModelDefaults.DEFAULT_SUPPORTS_IMAGE_INPUT;
+
+ public int getContextWindow() {
+ return contextWindow;
+ }
+
+ public void setContextWindow(int contextWindow) {
+ this.contextWindow = contextWindow;
+ }
+
+ public int getMaxOutputTokens() {
+ return maxOutputTokens;
+ }
+
+ public void setMaxOutputTokens(int maxOutputTokens) {
+ this.maxOutputTokens = maxOutputTokens;
+ }
+
+ public boolean isSupportsThinking() {
+ return supportsThinking;
+ }
+
+ public void setSupportsThinking(boolean supportsThinking) {
+ this.supportsThinking = supportsThinking;
+ }
+
+ public boolean isSupportsImageInput() {
+ return supportsImageInput;
+ }
+
+ public void setSupportsImageInput(boolean supportsImageInput) {
+ this.supportsImageInput = supportsImageInput;
+ }
+ }
+
public static class ProviderProperties {
private boolean enabled;
private boolean enabledConfigured;
diff --git a/lypi-boot/src/main/java/cn/lypi/boot/ai/OpenAiCompatibleProviderLoginService.java b/lypi-boot/src/main/java/cn/lypi/boot/ai/OpenAiCompatibleProviderLoginService.java
new file mode 100644
index 00000000..8eb59a69
--- /dev/null
+++ b/lypi-boot/src/main/java/cn/lypi/boot/ai/OpenAiCompatibleProviderLoginService.java
@@ -0,0 +1,222 @@
+package cn.lypi.boot.ai;
+
+import cn.lypi.ai.ProviderAdapterApiProvider;
+import cn.lypi.ai.RuntimeModelRegistry;
+import cn.lypi.ai.model.DiscoveredModel;
+import cn.lypi.ai.model.DiscoveredModelDefaults;
+import cn.lypi.ai.model.DiscoveredModelDescriptorMapper;
+import cn.lypi.ai.model.RemoteModelDiscoveryClient;
+import cn.lypi.ai.provider.RequestStyle;
+import cn.lypi.ai.provider.TransportMode;
+import cn.lypi.ai.provider.openai.OpenAiCompatibleProviderAdapter;
+import cn.lypi.ai.provider.openai.OpenAiProviderConfig;
+import cn.lypi.ai.transport.HttpSseProviderTransport;
+import cn.lypi.ai.transport.WebSocketProviderTransport;
+import cn.lypi.contracts.error.ErrorSeverity;
+import cn.lypi.contracts.error.ModelProviderException;
+import cn.lypi.contracts.model.ApiStyle;
+import cn.lypi.contracts.model.ModelDescriptor;
+import cn.lypi.contracts.runtime.ProviderLoginPort;
+import cn.lypi.contracts.runtime.ProviderLoginResult;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.time.Duration;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+/** Registers a verified OpenAI-compatible Chat Completions provider for the current process. */
+public final class OpenAiCompatibleProviderLoginService implements ProviderLoginPort {
+ private static final Pattern CHANNEL_NAME = Pattern.compile("[a-z0-9][a-z0-9_-]{0,63}");
+ private static final List DISCOVERY_PATHS = List.of("/models", "/model");
+ private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
+ private static final int MAX_RETRIES = 3;
+
+ private final RemoteModelDiscoveryClient discoveryClient;
+ private final RuntimeModelRegistry modelRegistry;
+ private final ProviderAdapterApiProvider openAiDispatcher;
+ private final LoginProviderPropertiesStore propertiesStore;
+ private final DiscoveredModelDefaults defaults;
+
+ public OpenAiCompatibleProviderLoginService(
+ RemoteModelDiscoveryClient discoveryClient,
+ RuntimeModelRegistry modelRegistry,
+ ProviderAdapterApiProvider openAiDispatcher,
+ LoginProviderPropertiesStore propertiesStore,
+ DiscoveredModelDefaults defaults
+ ) {
+ this.discoveryClient = Objects.requireNonNull(discoveryClient, "discoveryClient");
+ this.modelRegistry = Objects.requireNonNull(modelRegistry, "modelRegistry");
+ this.openAiDispatcher = Objects.requireNonNull(openAiDispatcher, "openAiDispatcher");
+ this.propertiesStore = Objects.requireNonNull(propertiesStore, "propertiesStore");
+ this.defaults = Objects.requireNonNull(defaults, "defaults");
+ }
+
+ @Override
+ public ProviderLoginResult register(String channelName, String rawBaseUrl, String authKey) {
+ String provider = requireChannelName(channelName);
+ URI baseUrl = normalizeBaseUrl(rawBaseUrl);
+ String requiredAuthKey = requireAuthKey(authKey);
+ List discovered = discoverModels(baseUrl, requiredAuthKey);
+ DiscoveredModelDescriptorMapper mapper = new DiscoveredModelDescriptorMapper(
+ provider,
+ baseUrl,
+ ApiStyle.OPENAI_COMPATIBLE,
+ defaults
+ );
+ List descriptors = discovered.stream().map(mapper::map).toList();
+ OpenAiCompatibleProviderAdapter adapter = chatCompletionsAdapter(provider, baseUrl, requiredAuthKey);
+
+ try {
+ propertiesStore.save(provider, baseUrl, requiredAuthKey);
+ } catch (IOException | RuntimeException error) {
+ throw providerLoginFailure(
+ "provider.login_persistence_failed",
+ "Provider login could not be saved."
+ );
+ }
+
+ openAiDispatcher.replaceAdapter(adapter);
+ modelRegistry.replaceProvider(provider, descriptors);
+ return new ProviderLoginResult(provider, descriptors);
+ }
+
+ private List discoverModels(URI baseUrl, String authKey) {
+ List discovered;
+ try {
+ discovered = discoveryClient.discoverModels(baseUrl, authKey, DISCOVERY_PATHS, REQUEST_TIMEOUT);
+ } catch (ModelProviderException error) {
+ if (error.getMessage() != null && error.getMessage().contains(authKey)) {
+ throw providerLoginFailure(
+ "provider.login_discovery_failed",
+ "Provider model discovery failed."
+ );
+ }
+ throw error;
+ } catch (RuntimeException error) {
+ throw providerLoginFailure(
+ "provider.login_discovery_failed",
+ "Provider model discovery failed."
+ );
+ }
+ if (discovered.isEmpty()) {
+ throw providerLoginFailure(
+ "model.discovery_unavailable",
+ "Remote model discovery returned no usable models."
+ );
+ }
+ return List.copyOf(discovered);
+ }
+
+ private static OpenAiCompatibleProviderAdapter chatCompletionsAdapter(
+ String provider,
+ URI baseUrl,
+ String authKey
+ ) {
+ OpenAiProviderConfig config = new OpenAiProviderConfig(
+ provider,
+ baseUrl,
+ Optional.empty(),
+ "/v1/responses",
+ authKey,
+ RequestStyle.CHAT_COMPLETIONS,
+ RequestStyle.CHAT_COMPLETIONS,
+ TransportMode.SSE,
+ REQUEST_TIMEOUT,
+ MAX_RETRIES,
+ Map.of("requires-reasoning-content-on-assistant-messages", true)
+ );
+ return new OpenAiCompatibleProviderAdapter(
+ config,
+ new WebSocketProviderTransport(),
+ new HttpSseProviderTransport(),
+ new HttpSseProviderTransport()
+ );
+ }
+
+ private static String requireChannelName(String channelName) {
+ if (channelName == null || !CHANNEL_NAME.matcher(channelName).matches()) {
+ throw providerLoginFailure(
+ "provider.login_invalid_channel_name",
+ "Provider channel name must match [a-z0-9][a-z0-9_-]{0,63}."
+ );
+ }
+ return channelName;
+ }
+
+ private static URI normalizeBaseUrl(String rawBaseUrl) {
+ if (rawBaseUrl == null || rawBaseUrl.isBlank()) {
+ throw providerLoginFailure(
+ "provider.login_invalid_base_url",
+ "Provider base URL must be an absolute HTTP(S) URL."
+ );
+ }
+ URI parsed;
+ try {
+ parsed = URI.create(rawBaseUrl.trim());
+ } catch (IllegalArgumentException error) {
+ throw providerLoginFailure(
+ "provider.login_invalid_base_url",
+ "Provider base URL must be an absolute HTTP(S) URL."
+ );
+ }
+ if (!parsed.isAbsolute()
+ || parsed.isOpaque()
+ || parsed.getHost() == null
+ || parsed.getRawUserInfo() != null
+ || parsed.getRawQuery() != null
+ || parsed.getRawFragment() != null
+ || !("http".equalsIgnoreCase(parsed.getScheme()) || "https".equalsIgnoreCase(parsed.getScheme()))) {
+ throw providerLoginFailure(
+ "provider.login_invalid_base_url",
+ "Provider base URL must be an absolute HTTP(S) URL."
+ );
+ }
+ String path = trimTrailingSlashes(parsed.getPath());
+ try {
+ return new URI(
+ parsed.getScheme().toLowerCase(Locale.ROOT),
+ null,
+ parsed.getHost().toLowerCase(Locale.ROOT),
+ parsed.getPort(),
+ path,
+ null,
+ null
+ );
+ } catch (URISyntaxException error) {
+ throw providerLoginFailure(
+ "provider.login_invalid_base_url",
+ "Provider base URL must be an absolute HTTP(S) URL."
+ );
+ }
+ }
+
+ private static String trimTrailingSlashes(String path) {
+ if (path == null || path.isEmpty() || "/".equals(path)) {
+ return null;
+ }
+ int end = path.length();
+ while (end > 0 && path.charAt(end - 1) == '/') {
+ end--;
+ }
+ return end == 0 ? null : path.substring(0, end);
+ }
+
+ private static String requireAuthKey(String authKey) {
+ if (authKey == null || authKey.isBlank()) {
+ throw providerLoginFailure(
+ "provider.login_invalid_auth_key",
+ "Provider auth key is required."
+ );
+ }
+ return authKey;
+ }
+
+ private static ModelProviderException providerLoginFailure(String errorId, String message) {
+ return new ModelProviderException(errorId, ErrorSeverity.ERROR, false, message);
+ }
+}
diff --git a/lypi-boot/src/main/java/cn/lypi/boot/runtime/LyPiRuntimeAutoConfiguration.java b/lypi-boot/src/main/java/cn/lypi/boot/runtime/LyPiRuntimeAutoConfiguration.java
index 62698d8c..91bbdf99 100644
--- a/lypi-boot/src/main/java/cn/lypi/boot/runtime/LyPiRuntimeAutoConfiguration.java
+++ b/lypi-boot/src/main/java/cn/lypi/boot/runtime/LyPiRuntimeAutoConfiguration.java
@@ -19,6 +19,7 @@
import cn.lypi.contracts.runtime.CompactStateBackfillPort;
import cn.lypi.contracts.runtime.CompactionRuntimePort;
import cn.lypi.contracts.runtime.LyPiRuntime;
+import cn.lypi.contracts.runtime.ProviderLoginPort;
import cn.lypi.contracts.runtime.ResourceRuntimePort;
import cn.lypi.contracts.runtime.SecurityRuntimePort;
import cn.lypi.contracts.runtime.SessionManagerFactoryPort;
@@ -429,9 +430,17 @@ public AppEntry appEntry(
public JLineTuiTransportFactory jLineTuiTransportFactory(
SessionManagerPort sessionManager,
ResourceRuntimePort resourceRuntime,
- CompactionRuntimePort compactionRuntime
+ CompactionRuntimePort compactionRuntime,
+ ObjectProvider modelCatalog,
+ ObjectProvider providerLogin
) {
- return RuntimeBeanFactories.jLineTuiTransportFactory(sessionManager, resourceRuntime, compactionRuntime);
+ return RuntimeBeanFactories.jLineTuiTransportFactory(
+ sessionManager,
+ resourceRuntime,
+ compactionRuntime,
+ modelCatalog.getIfAvailable(),
+ providerLogin.getIfAvailable()
+ );
}
/**
diff --git a/lypi-boot/src/main/java/cn/lypi/boot/runtime/RuntimeBeanFactories.java b/lypi-boot/src/main/java/cn/lypi/boot/runtime/RuntimeBeanFactories.java
index 8b29b864..3baae96c 100644
--- a/lypi-boot/src/main/java/cn/lypi/boot/runtime/RuntimeBeanFactories.java
+++ b/lypi-boot/src/main/java/cn/lypi/boot/runtime/RuntimeBeanFactories.java
@@ -33,6 +33,7 @@
import cn.lypi.contracts.runtime.CompactStateBackfillPort;
import cn.lypi.contracts.runtime.CompactionRuntimePort;
import cn.lypi.contracts.runtime.LyPiRuntime;
+import cn.lypi.contracts.runtime.ProviderLoginPort;
import cn.lypi.contracts.runtime.ResourceRuntimePort;
import cn.lypi.contracts.runtime.SecurityRuntimePort;
import cn.lypi.contracts.runtime.SessionManagerFactoryPort;
@@ -525,7 +526,9 @@ static AppEntry appEntry(
static JLineTuiTransportFactory jLineTuiTransportFactory(
SessionManagerPort sessionManager,
ResourceRuntimePort resourceRuntime,
- CompactionRuntimePort compactionRuntime
+ CompactionRuntimePort compactionRuntime,
+ ModelCatalogPort modelCatalog,
+ ProviderLoginPort providerLogin
) {
return (state, core, events, terminal, diffViewProvider, resumeController, newSessionController, slashCommands) ->
JLineTuiTransport.open(
@@ -539,7 +542,9 @@ static JLineTuiTransportFactory jLineTuiTransportFactory(
newSessionController,
sessionManager,
resourceRuntime,
- compactionRuntime
+ compactionRuntime,
+ modelCatalog,
+ providerLogin
);
}
diff --git a/lypi-boot/src/main/resources/application.yml b/lypi-boot/src/main/resources/application.yml
index 90fb7556..260a5458 100644
--- a/lypi-boot/src/main/resources/application.yml
+++ b/lypi-boot/src/main/resources/application.yml
@@ -1,6 +1,8 @@
spring:
config:
- import: optional:file:${user.home}/.ly-pi/application.yml
+ import:
+ - optional:file:${user.home}/.ly-pi/login-providers.properties
+ - optional:file:${user.home}/.ly-pi/application.yml
main:
banner-mode: off
diff --git a/lypi-boot/src/main/resources/application.yml.example b/lypi-boot/src/main/resources/application.yml.example
index 710883d8..39a11b18 100644
--- a/lypi-boot/src/main/resources/application.yml.example
+++ b/lypi-boot/src/main/resources/application.yml.example
@@ -119,6 +119,15 @@
# # NOTE: 确定性摘要器已删除,fallback_deterministic 仅保留旧配置兼容;当前行为与 skip_compaction 一样回到原上下文。
# fallback-policy: fallback_deterministic
#
+# model-discovery:
+# # 重要性:可选覆盖。远端模型目录缺少能力字段时使用这组全局默认描述。
+# # 优先级:静态同名完整模型描述 > 远端显式字段 > 本组默认值。
+# defaults:
+# context-window: 256000
+# max-output-tokens: 8192
+# supports-thinking: true
+# supports-image-input: true
+#
# # 重要性:可选覆盖或扩展。providers 用于覆盖内置 Provider 或新增 OpenAI 兼容或 Anthropic Provider。
# # 说明:内置 openai 可省略;配置 openai 会覆盖同名内置 adapter,配置新名称会追加 provider。
# # 关闭内置 openai 时,只需要取消注释:
@@ -225,7 +234,7 @@
# api-style: openai_compatible
# request-style: chat_completions
# fallback-request-style: chat_completions
-# transport: auto
+# transport: sse
# base-url: https://api.fixture.example/v1
# websocket-path: /v1/responses
# websocket-url:
@@ -234,10 +243,16 @@
# max-retries: 1
# compat:
# vendor: fixture
+# # 部分 Chat Completions 服务要求 assistant 工具调用历史携带 reasoning_content。
+# requires-reasoning-content-on-assistant-messages: true
# model-discovery:
-# enabled: false
+# # 启动时按顺序请求候选路径;所有路径都没有返回有效模型时,应用启动失败。
+# enabled: true
# paths:
# - /models
+# - /model
+# # 开启 model-discovery 后,静态 models 作为远端同名模型的完整描述覆盖;
+# # 远端未返回的静态 model-id 仍不会进入模型目录。
# models:
# - model-id: fixture-model
# context-window: 64000
diff --git a/lypi-boot/src/test/java/cn/lypi/boot/ApplicationExampleConfigTest.java b/lypi-boot/src/test/java/cn/lypi/boot/ApplicationExampleConfigTest.java
index c77106f0..2e3ac994 100644
--- a/lypi-boot/src/test/java/cn/lypi/boot/ApplicationExampleConfigTest.java
+++ b/lypi-boot/src/test/java/cn/lypi/boot/ApplicationExampleConfigTest.java
@@ -19,11 +19,13 @@
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
+import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.StandardEnvironment;
+import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.ClassPathResource;
class ApplicationExampleConfigTest {
@@ -109,6 +111,58 @@ void applicationExampleKeepsOpenAiThinkingSupportSeparateFromAnthropicLimitation
assertThat(openAiBlock).doesNotContain("Anthropic extended thinking");
}
+ @Test
+ void applicationExampleDocumentsDiscoveredChatCompletionsProvider() throws IOException {
+ String example = new ClassPathResource("application.yml.example").getContentAsString(StandardCharsets.UTF_8);
+ String fixtureBlock = example.substring(
+ example.indexOf("# fixture:"),
+ example.indexOf("# anthropic:")
+ );
+
+ assertThat(fixtureBlock).contains("# request-style: chat_completions");
+ assertThat(fixtureBlock).contains("# fallback-request-style: chat_completions");
+ assertThat(fixtureBlock).contains("# transport: sse");
+ assertThat(fixtureBlock).contains("# enabled: true");
+ assertThat(fixtureBlock).contains("# - /models");
+ assertThat(fixtureBlock).contains("# - /model");
+ assertThat(fixtureBlock).contains("作为远端同名模型的完整描述覆盖");
+ assertThat(fixtureBlock).contains("远端未返回的静态 model-id 仍不会进入模型目录");
+ assertThat(fixtureBlock)
+ .contains("# requires-reasoning-content-on-assistant-messages: true");
+ }
+
+ @Test
+ void applicationExampleParsesDiscoveryDefaultsAndChatCompatibility() throws IOException {
+ StandardEnvironment environment = environmentForAiExample();
+
+ assertThat(environment.getProperty(
+ "lypi.ai.model-discovery.defaults.context-window",
+ Integer.class
+ )).isEqualTo(256000);
+ assertThat(environment.getProperty(
+ "lypi.ai.model-discovery.defaults.max-output-tokens",
+ Integer.class
+ )).isEqualTo(8192);
+ assertThat(environment.getProperty(
+ "lypi.ai.model-discovery.defaults.supports-thinking",
+ Boolean.class
+ )).isTrue();
+ assertThat(environment.getProperty(
+ "lypi.ai.model-discovery.defaults.supports-image-input",
+ Boolean.class
+ )).isTrue();
+ assertThat(environment.getProperty("lypi.ai.providers.fixture.request-style"))
+ .isEqualTo("chat_completions");
+ assertThat(environment.getProperty("lypi.ai.providers.fixture.fallback-request-style"))
+ .isEqualTo("chat_completions");
+ assertThat(environment.getProperty("lypi.ai.providers.fixture.transport"))
+ .isEqualTo("sse");
+ assertThat(environment.getProperty(
+ "lypi.ai.providers.fixture.compat.requires-reasoning-content-on-assistant-messages",
+ Boolean.class
+ )).isTrue();
+ }
+
@Test
void applicationExampleDocumentsPermissionsAtLypiTopLevel() throws IOException {
String example = new ClassPathResource("application.yml.example").getContentAsString(StandardCharsets.UTF_8);
@@ -263,4 +317,24 @@ private Binder binderForExample() throws IOException {
.forEach(environment.getPropertySources()::addLast);
return Binder.get(environment);
}
+
+ private StandardEnvironment environmentForAiExample() throws IOException {
+ String example = new ClassPathResource("application.yml.example")
+ .getContentAsString(StandardCharsets.UTF_8);
+ String aiBlock = example.substring(
+ example.indexOf("# ai:"),
+ example.indexOf("# tool:")
+ );
+ String yaml = "lypi:\n" + aiBlock.lines()
+ .map(line -> line.equals("#") ? "" : line.startsWith("# ") ? line.substring(2) : line)
+ .collect(Collectors.joining("\n"));
+ StandardEnvironment environment = new StandardEnvironment();
+ new YamlPropertySourceLoader()
+ .load(
+ "application-example-ai",
+ new ByteArrayResource(yaml.getBytes(StandardCharsets.UTF_8))
+ )
+ .forEach(environment.getPropertySources()::addLast);
+ return environment;
+ }
}
diff --git a/lypi-boot/src/test/java/cn/lypi/boot/UserRootConfigurationTest.java b/lypi-boot/src/test/java/cn/lypi/boot/UserRootConfigurationTest.java
index b1bd5e3a..0f6e9a11 100644
--- a/lypi-boot/src/test/java/cn/lypi/boot/UserRootConfigurationTest.java
+++ b/lypi-boot/src/test/java/cn/lypi/boot/UserRootConfigurationTest.java
@@ -82,6 +82,33 @@ void systemPropertyOverridesUserRootConfiguration() throws Exception {
.isEqualTo(CompactionSummaryFallbackPolicy.FALLBACK_DETERMINISTIC));
}
+ @Test
+ void importsManagedLoginProviderConfigurationBeforeUserConfiguration() throws Exception {
+ Path home = Files.createDirectories(tempDir.resolve("login-provider-home"));
+ Path configRoot = Files.createDirectories(home.resolve(".ly-pi"));
+ Files.writeString(configRoot.resolve("login-providers.properties"), """
+ lypi.ai.providers.login-fixture.enabled=true
+ lypi.ai.providers.login-fixture.base-url=https://generated.test/v1
+ """);
+ Files.writeString(configRoot.resolve("application.yml"), """
+ lypi:
+ ai:
+ providers:
+ login-fixture:
+ base-url: https://user.test/v1
+ """);
+
+ runner(home).run(context -> {
+ LyPiAiProperties.ProviderProperties provider = context.getBean(LyPiAiProperties.class)
+ .getProviders()
+ .get("login-fixture");
+
+ assertThat(provider).isNotNull();
+ assertThat(provider.isEnabled()).isTrue();
+ assertThat(provider.getBaseUrl()).hasToString("https://user.test/v1");
+ });
+ }
+
private ApplicationContextRunner runner(Path home) {
return new ApplicationContextRunner()
.withInitializer(new ConfigDataApplicationContextInitializer())
diff --git a/lypi-boot/src/test/java/cn/lypi/boot/ai/LyPiAiAutoConfigurationTest.java b/lypi-boot/src/test/java/cn/lypi/boot/ai/LyPiAiAutoConfigurationTest.java
index 8d505fdd..bb95f7e3 100644
--- a/lypi-boot/src/test/java/cn/lypi/boot/ai/LyPiAiAutoConfigurationTest.java
+++ b/lypi-boot/src/test/java/cn/lypi/boot/ai/LyPiAiAutoConfigurationTest.java
@@ -1,12 +1,17 @@
package cn.lypi.boot.ai;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
import cn.lypi.ai.ApiProviderRegistry;
import cn.lypi.ai.ModelPort;
import cn.lypi.ai.ModelRegistry;
+import cn.lypi.ai.ProviderAdapterApiProvider;
+import cn.lypi.ai.RuntimeModelRegistry;
+import cn.lypi.ai.model.DiscoveredModel;
import cn.lypi.ai.model.RemoteModelDiscoveryClient;
import cn.lypi.ai.provider.RequestStyle;
+import cn.lypi.ai.provider.TransportMode;
import cn.lypi.ai.provider.anthropic.AnthropicCompatibleProviderAdapter;
import cn.lypi.ai.provider.anthropic.AnthropicProviderConfig;
import cn.lypi.ai.provider.openai.OpenAiCompatibleProviderAdapter;
@@ -14,16 +19,29 @@
import cn.lypi.agent.compact.AiCompactionSummarizer;
import cn.lypi.agent.compact.CompactionSummarizer;
import cn.lypi.agent.compact.CompactionSummaryFallbackPolicy;
+import cn.lypi.contracts.error.ErrorSeverity;
+import cn.lypi.contracts.error.ModelProviderException;
import cn.lypi.contracts.model.ModelDescriptor;
+import cn.lypi.contracts.runtime.ProviderLoginPort;
+import cn.lypi.contracts.runtime.ProviderLoginResult;
import java.lang.reflect.Field;
import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
+import java.util.Optional;
+import java.util.OptionalInt;
+import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
class LyPiAiAutoConfigurationTest {
+ @TempDir
+ Path tempDir;
+
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(LyPiAiAutoConfiguration.class)
.withPropertyValues(
@@ -160,6 +178,87 @@ void explicitOpenAiProviderDisableRemovesBuiltInAdapter() {
});
}
+ @Test
+ void exposesMutableOpenAiDispatcherEvenWithoutInitialOpenAiAdapter() {
+ new ApplicationContextRunner()
+ .withUserConfiguration(LyPiAiAutoConfiguration.class)
+ .withPropertyValues("lypi.ai.providers.openai.enabled=false")
+ .run(context -> {
+ assertThat(context).hasSingleBean(ProviderAdapterApiProvider.class);
+ assertThat(context).hasSingleBean(RuntimeModelRegistry.class);
+ assertThat(context).hasSingleBean(ProviderLoginPort.class);
+ assertThat(context.getBean("openAiCompatibleProviderAdapters", List.class)).isEmpty();
+ assertThat(context.getBean(ApiProviderRegistry.class)
+ .find(cn.lypi.contracts.model.ApiStyle.OPENAI_COMPATIBLE)).isPresent();
+ });
+ }
+
+ @Test
+ void exposesUnavailableLoginPortWhenModelRegistryIsReplacedWithoutRuntimeMutationSupport() {
+ new ApplicationContextRunner()
+ .withUserConfiguration(LyPiAiAutoConfiguration.class)
+ .withBean(ModelRegistry.class, () -> new ModelRegistry() {
+ @Override
+ public List list() {
+ return List.of();
+ }
+
+ @Override
+ public java.util.Optional find(cn.lypi.contracts.model.ModelSelection selection) {
+ return java.util.Optional.empty();
+ }
+ })
+ .run(context -> assertThatThrownBy(() -> context.getBean(ProviderLoginPort.class)
+ .register("zen", "https://example.test/v1", "fixture-key"))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("provider login is unavailable"));
+ }
+
+ @Test
+ void importsManagedLoginProviderAndDiscoversItsModelsAtStartup() throws Exception {
+ Path home = Files.createDirectories(tempDir.resolve("home"));
+ Path configRoot = Files.createDirectories(home.resolve(".ly-pi"));
+ Files.writeString(configRoot.resolve("login-providers.properties"), """
+ lypi.ai.providers.login-fixture.enabled=true
+ lypi.ai.providers.login-fixture.api-style=openai_compatible
+ lypi.ai.providers.login-fixture.base-url=https://fixture.test/v1
+ lypi.ai.providers.login-fixture.api-key=fixture-login-key
+ lypi.ai.providers.login-fixture.request-style=chat_completions
+ lypi.ai.providers.login-fixture.fallback-request-style=chat_completions
+ lypi.ai.providers.login-fixture.transport=sse
+ lypi.ai.providers.login-fixture.model-discovery.enabled=true
+ lypi.ai.providers.login-fixture.model-discovery.paths[0]=/models
+ lypi.ai.providers.login-fixture.model-discovery.paths[1]=/model
+ lypi.ai.providers.login-fixture.compat.requires-reasoning-content-on-assistant-messages=true
+ """);
+
+ new ApplicationContextRunner()
+ .withInitializer(new ConfigDataApplicationContextInitializer())
+ .withUserConfiguration(LyPiAiAutoConfiguration.class)
+ .withBean(RemoteModelDiscoveryClient.class, () -> new FixedRemoteModelDiscoveryClient("discovered-login-model"))
+ .withSystemProperties("user.home=" + home)
+ .run(context -> {
+ ModelDescriptor descriptor = model(
+ context.getBean(ModelRegistry.class),
+ "login-fixture",
+ "discovered-login-model"
+ );
+
+ assertThat(descriptor.baseUrl()).hasToString("https://fixture.test/v1");
+ assertThat(context.getBean(ProviderLoginPort.class)).isNotNull();
+ assertThat(context.getBean(ApiProviderRegistry.class)
+ .find(cn.lypi.contracts.model.ApiStyle.OPENAI_COMPATIBLE)).isPresent();
+ List> adapters = context.getBean("openAiCompatibleProviderAdapters", List.class);
+ OpenAiCompatibleProviderAdapter adapter = adapters.stream()
+ .map(OpenAiCompatibleProviderAdapter.class::cast)
+ .filter(candidate -> config(candidate).provider().equals("login-fixture"))
+ .findFirst()
+ .orElseThrow();
+ assertThat(config(adapter).compat().get("requires-reasoning-content-on-assistant-messages"))
+ .isIn(true, "true");
+ });
+ }
+
@Test
void doesNotTriggerRemoteDiscoveryWhenDisabled() {
new ApplicationContextRunner()
@@ -196,9 +295,210 @@ void configuredModelDescriptorOverridesRemoteAndBuiltInDescriptors() {
"lypi.ai.providers.openai.models[1].max-output-tokens=8192"
)
.run(context -> {
- ModelDescriptor descriptor = openAiModel(context.getBean(ModelRegistry.class), "gpt-5-mini");
+ ModelRegistry registry = context.getBean(ModelRegistry.class);
+ ModelDescriptor descriptor = openAiModel(registry, "gpt-5-mini");
assertThat(descriptor.contextWindow()).isEqualTo(64_000);
+ assertThat(registry.list())
+ .filteredOn(model -> model.provider().equals("openai"))
+ .extracting(ModelDescriptor::modelId)
+ .containsExactly("gpt-5-mini");
+ });
+ }
+
+ @Test
+ void appliesConfiguredDiscoveryDefaultsOnlyToMissingRemoteMetadata() {
+ new ApplicationContextRunner()
+ .withUserConfiguration(LyPiAiAutoConfiguration.class)
+ .withBean(RemoteModelDiscoveryClient.class, () -> new FixedRemoteModelDiscoveryClient(List.of(
+ new DiscoveredModel(
+ "remote-explicit",
+ OptionalInt.of(128_000),
+ OptionalInt.of(16_384),
+ Optional.of(false),
+ Optional.of(false)
+ ),
+ DiscoveredModel.idOnly("remote-defaulted")
+ )))
+ .withPropertyValues(
+ "lypi.ai.model-discovery.defaults.context-window=192000",
+ "lypi.ai.model-discovery.defaults.max-output-tokens=12288",
+ "lypi.ai.model-discovery.defaults.supports-thinking=true",
+ "lypi.ai.model-discovery.defaults.supports-image-input=false",
+ "lypi.ai.providers.fixture.enabled=true",
+ "lypi.ai.providers.fixture.api-style=openai_compatible",
+ "lypi.ai.providers.fixture.base-url=https://api.fixture.test/v1",
+ "lypi.ai.providers.fixture.model-discovery.enabled=true"
+ )
+ .run(context -> {
+ ModelRegistry registry = context.getBean(ModelRegistry.class);
+
+ assertThat(model(registry, "fixture", "remote-explicit")).satisfies(descriptor -> {
+ assertThat(descriptor.contextWindow()).isEqualTo(128_000);
+ assertThat(descriptor.maxOutputTokens()).isEqualTo(16_384);
+ assertThat(descriptor.supportsThinking()).isFalse();
+ assertThat(descriptor.supportsImageInput()).isFalse();
+ });
+ assertThat(model(registry, "fixture", "remote-defaulted")).satisfies(descriptor -> {
+ assertThat(descriptor.contextWindow()).isEqualTo(192_000);
+ assertThat(descriptor.maxOutputTokens()).isEqualTo(12_288);
+ assertThat(descriptor.supportsThinking()).isTrue();
+ assertThat(descriptor.supportsImageInput()).isFalse();
+ });
+ });
+ }
+
+ @Test
+ void passesConfiguredDiscoveryDefaultsToRuntimeProviderLogin() {
+ Path home = tempDir.resolve("runtime-login-home");
+
+ new ApplicationContextRunner()
+ .withUserConfiguration(LyPiAiAutoConfiguration.class)
+ .withBean(RemoteModelDiscoveryClient.class, () -> new FixedRemoteModelDiscoveryClient("runtime-model"))
+ .withBean(LoginProviderPropertiesStore.class, () -> new LoginProviderPropertiesStore(home))
+ .withPropertyValues(
+ "lypi.ai.model-discovery.defaults.context-window=192000",
+ "lypi.ai.model-discovery.defaults.max-output-tokens=12288",
+ "lypi.ai.model-discovery.defaults.supports-thinking=true",
+ "lypi.ai.model-discovery.defaults.supports-image-input=false",
+ "lypi.ai.providers.openai.enabled=false"
+ )
+ .run(context -> {
+ ProviderLoginResult result = context.getBean(ProviderLoginPort.class).register(
+ "zen",
+ "https://api.fixture.test/v1",
+ "fixture-key"
+ );
+
+ assertThat(result.models()).singleElement().satisfies(descriptor -> {
+ assertThat(descriptor.contextWindow()).isEqualTo(192_000);
+ assertThat(descriptor.maxOutputTokens()).isEqualTo(12_288);
+ assertThat(descriptor.supportsThinking()).isTrue();
+ assertThat(descriptor.supportsImageInput()).isFalse();
+ });
+ });
+ }
+
+ @Test
+ void discoveredModelsAreAuthoritativeWhileMatchingLocalMetadataOverridesDefaults() {
+ new ApplicationContextRunner()
+ .withUserConfiguration(LyPiAiAutoConfiguration.class)
+ .withBean(RemoteModelDiscoveryClient.class, () -> new FixedRemoteModelDiscoveryClient(List.of(
+ new DiscoveredModel(
+ "remote-a",
+ OptionalInt.of(128_000),
+ OptionalInt.of(16_384),
+ Optional.of(false),
+ Optional.of(false)
+ )
+ )))
+ .withPropertyValues(
+ "lypi.ai.providers.fixture.enabled=true",
+ "lypi.ai.providers.fixture.api-style=openai_compatible",
+ "lypi.ai.providers.fixture.base-url=https://api.fixture.test/v1",
+ "lypi.ai.providers.fixture.model-discovery.enabled=true",
+ "lypi.ai.providers.fixture.models[0].model-id=remote-a",
+ "lypi.ai.providers.fixture.models[0].context-window=96000",
+ "lypi.ai.providers.fixture.models[0].max-output-tokens=8192",
+ "lypi.ai.providers.fixture.models[0].supports-thinking=true",
+ "lypi.ai.providers.fixture.models[0].supports-image-input=true",
+ "lypi.ai.providers.fixture.models[1].model-id=local-only",
+ "lypi.ai.providers.fixture.models[1].context-window=64000",
+ "lypi.ai.providers.fixture.models[1].max-output-tokens=4096"
+ )
+ .run(context -> {
+ ModelRegistry registry = context.getBean(ModelRegistry.class);
+
+ assertThat(registry.list())
+ .filteredOn(model -> model.provider().equals("fixture"))
+ .extracting(ModelDescriptor::modelId)
+ .containsExactly("remote-a");
+ assertThat(model(registry, "fixture", "remote-a")).satisfies(descriptor -> {
+ assertThat(descriptor.contextWindow()).isEqualTo(96_000);
+ assertThat(descriptor.maxOutputTokens()).isEqualTo(8_192);
+ assertThat(descriptor.supportsThinking()).isTrue();
+ assertThat(descriptor.supportsImageInput()).isTrue();
+ });
+ });
+ }
+
+ @Test
+ void rejectsNonPositiveDiscoveryDefaultTokenLimits() {
+ new ApplicationContextRunner()
+ .withUserConfiguration(LyPiAiAutoConfiguration.class)
+ .withPropertyValues("lypi.ai.model-discovery.defaults.context-window=0")
+ .run(context -> {
+ assertThat(context).hasFailed();
+ assertThat(rootCause(context.getStartupFailure()))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("Model discovery default token limits must be positive.");
+ });
+ }
+
+ @Test
+ void discoversEachProviderOnceWhileCreatingTheRegistry() {
+ CountingRemoteModelDiscoveryClient discovery = new CountingRemoteModelDiscoveryClient("remote-a");
+
+ new ApplicationContextRunner()
+ .withUserConfiguration(LyPiAiAutoConfiguration.class)
+ .withBean(RemoteModelDiscoveryClient.class, () -> discovery)
+ .withPropertyValues(
+ "lypi.ai.providers.fixture.enabled=true",
+ "lypi.ai.providers.fixture.api-style=openai_compatible",
+ "lypi.ai.providers.fixture.base-url=https://api.fixture.test/v1",
+ "lypi.ai.providers.fixture.model-discovery.enabled=true"
+ )
+ .run(context -> {
+ assertThat(context).hasSingleBean(ModelRegistry.class);
+ assertThat(discovery.calls()).isOne();
+ });
+ }
+
+ @Test
+ void failsStartupWhenRemoteModelDiscoveryIsUnavailable() {
+ new ApplicationContextRunner()
+ .withUserConfiguration(LyPiAiAutoConfiguration.class)
+ .withBean(RemoteModelDiscoveryClient.class, FailingRemoteModelDiscoveryClient::new)
+ .withPropertyValues(
+ "lypi.ai.providers.fixture.enabled=true",
+ "lypi.ai.providers.fixture.api-style=openai_compatible",
+ "lypi.ai.providers.fixture.base-url=https://api.fixture.test/v1",
+ "lypi.ai.providers.fixture.model-discovery.enabled=true"
+ )
+ .run(context -> {
+ assertThat(context).hasFailed();
+ assertThat(rootCause(context.getStartupFailure()))
+ .isInstanceOfSatisfying(ModelProviderException.class, error ->
+ assertThat(error.errorId()).isEqualTo("model.discovery_unavailable"));
+ });
+ }
+
+ @Test
+ void configuresDiscoveredCompatibleProviderForChatCompletionsSseOnly() {
+ new ApplicationContextRunner()
+ .withUserConfiguration(LyPiAiAutoConfiguration.class)
+ .withBean(RemoteModelDiscoveryClient.class, () -> new FixedRemoteModelDiscoveryClient("remote-a"))
+ .withPropertyValues(
+ "lypi.ai.providers.fixture.enabled=true",
+ "lypi.ai.providers.fixture.api-style=openai_compatible",
+ "lypi.ai.providers.fixture.base-url=https://api.fixture.test/v1",
+ "lypi.ai.providers.fixture.api-key=${LYPI_FIXTURE_TOKEN}",
+ "lypi.ai.providers.fixture.request-style=chat_completions",
+ "lypi.ai.providers.fixture.fallback-request-style=chat_completions",
+ "lypi.ai.providers.fixture.transport=sse",
+ "lypi.ai.providers.fixture.model-discovery.enabled=true"
+ )
+ .run(context -> {
+ List> adapters = context.getBean("openAiCompatibleProviderAdapters", List.class);
+ OpenAiCompatibleProviderAdapter adapter = adapters.stream()
+ .map(OpenAiCompatibleProviderAdapter.class::cast)
+ .filter(candidate -> config(candidate).provider().equals("fixture"))
+ .findFirst()
+ .orElseThrow();
+
+ assertThat(config(adapter).requestStyle()).isEqualTo(RequestStyle.CHAT_COMPLETIONS);
+ assertThat(config(adapter).fallbackRequestStyle()).isEqualTo(RequestStyle.CHAT_COMPLETIONS);
+ assertThat(config(adapter).transportMode()).isEqualTo(TransportMode.SSE);
});
}
@@ -381,21 +681,56 @@ void bindsProviderPropertiesFromYamlResources() {
private static final class ThrowingRemoteModelDiscoveryClient extends RemoteModelDiscoveryClient {
@Override
- public List discover(URI baseUrl, String apiKey, List paths, Duration timeout) {
+ public List discoverModels(URI baseUrl, String apiKey, List paths, Duration timeout) {
throw new AssertionError("Remote discovery should not be called when disabled.");
}
}
private static final class FixedRemoteModelDiscoveryClient extends RemoteModelDiscoveryClient {
- private final String modelId;
+ private final List models;
private FixedRemoteModelDiscoveryClient(String modelId) {
+ this(List.of(DiscoveredModel.idOnly(modelId)));
+ }
+
+ private FixedRemoteModelDiscoveryClient(List models) {
+ this.models = List.copyOf(models);
+ }
+
+ @Override
+ public List discoverModels(URI baseUrl, String apiKey, List paths, Duration timeout) {
+ return models;
+ }
+ }
+
+ private static final class CountingRemoteModelDiscoveryClient extends RemoteModelDiscoveryClient {
+ private final AtomicInteger calls = new AtomicInteger();
+ private final String modelId;
+
+ private CountingRemoteModelDiscoveryClient(String modelId) {
this.modelId = modelId;
}
@Override
- public List discover(URI baseUrl, String apiKey, List paths, Duration timeout) {
- return List.of(modelId);
+ public List discoverModels(URI baseUrl, String apiKey, List paths, Duration timeout) {
+ calls.incrementAndGet();
+ return List.of(DiscoveredModel.idOnly(modelId));
+ }
+
+ private int calls() {
+ return calls.get();
+ }
+ }
+
+ private static final class FailingRemoteModelDiscoveryClient extends RemoteModelDiscoveryClient {
+ @Override
+ public List discoverModels(URI baseUrl, String apiKey, List paths, Duration timeout) {
+ throw new ModelProviderException(
+ "model.discovery_unavailable",
+ ErrorSeverity.ERROR,
+ false,
+ "Remote model discovery returned no usable models."
+ );
}
}
@@ -420,11 +755,23 @@ private static AnthropicProviderConfig anthropicConfig(AnthropicCompatibleProvid
}
private static ModelDescriptor openAiModel(ModelRegistry registry, String modelId) {
+ return model(registry, "openai", modelId);
+ }
+
+ private static ModelDescriptor model(ModelRegistry registry, String provider, String modelId) {
return registry.list().stream()
- .filter(descriptor -> descriptor.provider().equals("openai"))
+ .filter(descriptor -> descriptor.provider().equals(provider))
.filter(descriptor -> descriptor.modelId().equals(modelId))
.findFirst()
- .orElseThrow(() -> new AssertionError("Missing openai model: " + modelId));
+ .orElseThrow(() -> new AssertionError("Missing model: " + provider + "/" + modelId));
+ }
+
+ private static Throwable rootCause(Throwable failure) {
+ Throwable current = failure;
+ while (current != null && current.getCause() != null) {
+ current = current.getCause();
+ }
+ return current;
}
}
diff --git a/lypi-boot/src/test/java/cn/lypi/boot/ai/OpenAiCompatibleLoginRealEndToEndTest.java b/lypi-boot/src/test/java/cn/lypi/boot/ai/OpenAiCompatibleLoginRealEndToEndTest.java
new file mode 100644
index 00000000..54899410
--- /dev/null
+++ b/lypi-boot/src/test/java/cn/lypi/boot/ai/OpenAiCompatibleLoginRealEndToEndTest.java
@@ -0,0 +1,460 @@
+package cn.lypi.boot.ai;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import cn.lypi.ai.DefaultApiProviderRegistry;
+import cn.lypi.ai.DefaultModelPort;
+import cn.lypi.ai.DefaultModelRegistry;
+import cn.lypi.ai.ProviderAdapterApiProvider;
+import cn.lypi.ai.RuntimeModelRegistry;
+import cn.lypi.ai.model.DiscoveredModelDefaults;
+import cn.lypi.ai.model.RemoteModelDiscoveryClient;
+import cn.lypi.contracts.common.JsonSchema;
+import cn.lypi.contracts.context.AgentMessage;
+import cn.lypi.contracts.context.AttachmentContentBlock;
+import cn.lypi.contracts.context.ContentBlock;
+import cn.lypi.contracts.context.ContextBudget;
+import cn.lypi.contracts.context.ContextSnapshot;
+import cn.lypi.contracts.context.MessageKind;
+import cn.lypi.contracts.context.MessageRole;
+import cn.lypi.contracts.context.TextContentBlock;
+import cn.lypi.contracts.context.ThinkingContentBlock;
+import cn.lypi.contracts.context.ToolCallContentBlock;
+import cn.lypi.contracts.context.ToolResultContentBlock;
+import cn.lypi.contracts.model.ApiStyle;
+import cn.lypi.contracts.model.AssistantDone;
+import cn.lypi.contracts.model.AssistantEventStream;
+import cn.lypi.contracts.model.AssistantStreamEvent;
+import cn.lypi.contracts.model.CostProfile;
+import cn.lypi.contracts.model.ModelDescriptor;
+import cn.lypi.contracts.model.ModelSelection;
+import cn.lypi.contracts.model.TextDelta;
+import cn.lypi.contracts.model.ThinkingDelta;
+import cn.lypi.contracts.model.ThinkingLevel;
+import cn.lypi.contracts.model.ToolCallDelta;
+import cn.lypi.contracts.prompt.SystemPrompt;
+import cn.lypi.contracts.runtime.ProviderLoginResult;
+import cn.lypi.contracts.security.AgentMode;
+import cn.lypi.contracts.security.PermissionMode;
+import cn.lypi.contracts.tool.ToolDescriptor;
+import cn.lypi.contracts.tool.ToolRegistrySnapshot;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.IOException;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.net.URI;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.io.TempDir;
+
+class OpenAiCompatibleLoginRealEndToEndTest {
+ private static final URI BASE_URL = URI.create(System.getProperty(
+ "lypi.openai-compatible.e2e.base-url",
+ "https://opencode.ai/zen/go/v1"
+ ));
+ private static final String CHANNEL = "compat-live";
+ private static final String THINKING_MODEL = System.getProperty(
+ "lypi.openai-compatible.e2e.thinking-model",
+ "deepseek-v4-flash"
+ );
+ private static final String IMAGE_MODEL = System.getProperty(
+ "lypi.openai-compatible.e2e.image-model",
+ "kimi-k2.6"
+ );
+ private static final String TOOL_NAME = "lypi_compatibility_probe";
+ private static final String TOOL_TOKEN = "LIVE_TOOL_OK";
+ private static final String IMAGE_TOKEN = "RED_BLUE";
+ private static final String RED_BLUE_PNG = "data:image/png;base64,"
+ + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR4nGP4z8DAwPAfAAcAAf9+CLHQAAAAAElFTkSuQmCC";
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ @Timeout(value = 300, unit = TimeUnit.SECONDS)
+ void logsInAndStreamsThinkingToolContinuationAndImageChatCompletions() {
+ assumeTrue(
+ Boolean.getBoolean("lypi.openai-compatible.e2e"),
+ "Enable with -Dlypi.openai-compatible.e2e=true"
+ );
+ String apiKey = apiKey();
+ RuntimeModelRegistry modelRegistry = new DefaultModelRegistry(List.of());
+ ProviderAdapterApiProvider dispatcher = new ProviderAdapterApiProvider(
+ ApiStyle.OPENAI_COMPATIBLE,
+ List.of()
+ );
+ Path home = tempDir.resolve("home");
+ OpenAiCompatibleProviderLoginService loginService = new OpenAiCompatibleProviderLoginService(
+ new RemoteModelDiscoveryClient(),
+ modelRegistry,
+ dispatcher,
+ new LoginProviderPropertiesStore(home),
+ discoveredDefaults()
+ );
+
+ ProviderLoginResult login = safely(
+ "OpenAI-compatible login",
+ () -> loginService.register(CHANNEL, BASE_URL.toString(), apiKey)
+ );
+ assertThat(login.provider()).isEqualTo(CHANNEL);
+ assertThat(login.models()).isNotEmpty();
+ assertThat(login.models()).allSatisfy(model -> {
+ assertThat(model.provider()).isEqualTo(CHANNEL);
+ assertThat(model.contextWindow()).isPositive();
+ assertThat(model.maxOutputTokens()).isPositive();
+ });
+ assertManagedProperties(home.resolve(".ly-pi/login-providers.properties"));
+ assertThat(modelRegistry.list()).containsExactlyElementsOf(login.models());
+
+ ModelDescriptor thinkingDescriptor = requireModel(login, THINKING_MODEL, "thinking and tool");
+ assertThat(thinkingDescriptor.supportsThinking()).isTrue();
+ ModelDescriptor imageDescriptor = requireModel(login, IMAGE_MODEL, "image");
+ assertThat(imageDescriptor.supportsImageInput()).isTrue();
+ DefaultModelPort modelPort = new DefaultModelPort(
+ modelRegistry,
+ new DefaultApiProviderRegistry(List.of(dispatcher))
+ );
+ List allEvents = new ArrayList<>();
+
+ List thinkingEvents = stream(
+ modelPort,
+ context(
+ thinkingDescriptor,
+ ThinkingLevel.HIGH,
+ List.of(userMessage(
+ "thinking-user",
+ "Think briefly, then answer in one short sentence that contains LIVE_THINKING_OK."
+ ))
+ ),
+ new ToolRegistrySnapshot(List.of()),
+ "HIGH thinking chat"
+ );
+ allEvents.addAll(thinkingEvents);
+ assertThat(joinedThinking(thinkingEvents)).isNotBlank();
+ assertThat(joinedText(thinkingEvents)).isNotBlank();
+ assertCompleted(thinkingEvents);
+
+ ToolRegistrySnapshot tools = toolRegistry();
+ AgentMessage toolPrompt = userMessage(
+ "tool-user",
+ "Call lypi_compatibility_probe exactly once with value LIVE_TOOL_OK. "
+ + "Do not answer before calling it. After receiving the tool result, return LIVE_TOOL_OK."
+ );
+ List toolEvents = stream(
+ modelPort,
+ context(thinkingDescriptor, ThinkingLevel.HIGH, List.of(toolPrompt)),
+ tools,
+ "tool-call chat"
+ );
+ allEvents.addAll(toolEvents);
+ String toolThinking = joinedThinking(toolEvents);
+ assertThat(toolThinking).isNotBlank();
+ ToolCallDelta toolCall = completeToolCall(toolEvents);
+ assertThat(toolCall.toolName()).isEqualTo(TOOL_NAME);
+ assertThat(toolCall.toolUseId()).isNotBlank();
+ assertThat(toolCall.partialInput()).containsEntry("value", TOOL_TOKEN);
+ assertCompleted(toolEvents);
+
+ AgentMessage assistantToolCall = assistantToolCall(toolCall, toolThinking, joinedText(toolEvents));
+ AgentMessage toolResult = toolResult(toolCall.toolUseId());
+ List continuationEvents = stream(
+ modelPort,
+ context(
+ thinkingDescriptor,
+ ThinkingLevel.HIGH,
+ List.of(toolPrompt, assistantToolCall, toolResult)
+ ),
+ new ToolRegistrySnapshot(List.of()),
+ "tool-result continuation chat"
+ );
+ allEvents.addAll(continuationEvents);
+ assertThat(joinedText(continuationEvents)).contains(TOOL_TOKEN);
+ assertCompleted(continuationEvents);
+
+ ContextSnapshot imageContext = context(
+ imageDescriptor,
+ ThinkingLevel.OFF,
+ List.of(imageMessage())
+ );
+ List imageEvents = stream(
+ modelPort,
+ imageContext,
+ new ToolRegistrySnapshot(List.of()),
+ "image chat"
+ );
+ allEvents.addAll(imageEvents);
+ assertThat(joinedText(imageEvents)).contains(IMAGE_TOKEN);
+ assertCompleted(imageEvents);
+
+ String eventTypes = allEvents.stream()
+ .map(event -> event.getClass().getSimpleName())
+ .distinct()
+ .sorted()
+ .collect(Collectors.joining(","));
+ System.out.printf(
+ "OpenAI-compatible E2E models=%d thinkingModel=%s toolModel=%s imageModel=%s events=%s%n",
+ login.models().size(),
+ THINKING_MODEL,
+ THINKING_MODEL,
+ IMAGE_MODEL,
+ eventTypes
+ );
+ }
+
+ private static String apiKey() {
+ String configured = System.getProperty("lypi.openai-compatible.e2e.api-key");
+ if (configured != null && !configured.isBlank()) {
+ return configured;
+ }
+ String authEntry = System.getProperty(
+ "lypi.openai-compatible.e2e.auth-entry",
+ "opencode-go"
+ );
+ Path authFile = Path.of(System.getProperty("user.home"), ".pi", "agent", "auth.json");
+ try {
+ JsonNode credential = new ObjectMapper().readTree(authFile.toFile()).path(authEntry);
+ String apiKey = credential.path("key").asText();
+ if (!apiKey.isBlank()) {
+ return apiKey;
+ }
+ } catch (IOException | RuntimeException error) {
+ throw new AssertionError("Unable to load the configured E2E credential.");
+ }
+ throw new AssertionError("The configured E2E credential is missing an API key.");
+ }
+
+ private static DiscoveredModelDefaults discoveredDefaults() {
+ return new DiscoveredModelDefaults(
+ DiscoveredModelDefaults.DEFAULT_CONTEXT_WINDOW,
+ DiscoveredModelDefaults.DEFAULT_MAX_OUTPUT_TOKENS,
+ DiscoveredModelDefaults.DEFAULT_SUPPORTS_THINKING,
+ DiscoveredModelDefaults.DEFAULT_SUPPORTS_IMAGE_INPUT,
+ new CostProfile(BigDecimal.ZERO, BigDecimal.ZERO, "USD"),
+ Map.of()
+ );
+ }
+
+ private static ModelDescriptor requireModel(
+ ProviderLoginResult login,
+ String modelId,
+ String purpose
+ ) {
+ return login.models().stream()
+ .filter(model -> model.modelId().equals(modelId))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError(
+ "Required " + purpose + " E2E model is unavailable: " + modelId
+ ));
+ }
+
+ private static void assertManagedProperties(Path storeFile) {
+ assertThat(storeFile).exists();
+ Properties properties = new Properties();
+ try (InputStream input = Files.newInputStream(storeFile)) {
+ properties.load(input);
+ } catch (IOException error) {
+ throw new AssertionError("Unable to inspect managed E2E provider properties.");
+ }
+ boolean cachesModels = properties.stringPropertyNames().stream()
+ .anyMatch(name -> name.contains(".models["));
+ boolean disablesThinking = properties.stringPropertyNames().stream()
+ .filter(name -> name.endsWith(".supports-thinking"))
+ .map(properties::getProperty)
+ .anyMatch("false"::equalsIgnoreCase);
+ assertThat(cachesModels).as("managed login properties cache no model list").isFalse();
+ assertThat(disablesThinking).as("managed login properties do not disable thinking").isFalse();
+ }
+
+ private static ContextSnapshot context(
+ ModelDescriptor descriptor,
+ ThinkingLevel thinkingLevel,
+ List messages
+ ) {
+ int autoCompactThreshold = (int) Math.min(
+ Integer.MAX_VALUE,
+ Math.max(1L, descriptor.contextWindow() * 4L / 5L)
+ );
+ return new ContextSnapshot(
+ new SystemPrompt(
+ "Follow the user's compatibility-test instructions exactly.",
+ List.of("e2e"),
+ "e2e"
+ ),
+ messages,
+ new ModelSelection(CHANNEL, descriptor.modelId(), thinkingLevel),
+ thinkingLevel,
+ AgentMode.EXECUTE,
+ PermissionMode.ASK,
+ new ContextBudget(
+ 0,
+ descriptor.contextWindow(),
+ autoCompactThreshold,
+ descriptor.maxOutputTokens(),
+ Math.min(8_192, descriptor.maxOutputTokens()),
+ 0,
+ 0,
+ BigDecimal.ZERO
+ )
+ );
+ }
+
+ private static AgentMessage userMessage(String id, String text) {
+ return message(id, MessageRole.USER, MessageKind.TEXT, List.of(new TextContentBlock(text)));
+ }
+
+ private static AgentMessage imageMessage() {
+ return message(
+ "image-user",
+ MessageRole.USER,
+ MessageKind.ATTACHMENT,
+ List.of(
+ new TextContentBlock(
+ "Inspect the attached two-pixel image. If its left pixel is red and its right pixel is blue, "
+ + "reply with exactly RED_BLUE."
+ ),
+ new AttachmentContentBlock(
+ "red-blue-image",
+ "two-pixel red and blue image",
+ "image/png",
+ Map.of("imageUrl", RED_BLUE_PNG, "detail", "high")
+ )
+ )
+ );
+ }
+
+ private static AgentMessage assistantToolCall(
+ ToolCallDelta toolCall,
+ String thinking,
+ String text
+ ) {
+ List content = new ArrayList<>();
+ if (!thinking.isBlank()) {
+ content.add(new ThinkingContentBlock(thinking));
+ }
+ if (!text.isBlank()) {
+ content.add(new TextContentBlock(text));
+ }
+ content.add(new ToolCallContentBlock(
+ toolCall.toolUseId(),
+ toolCall.toolName(),
+ "",
+ Map.of("input", toolCall.partialInput())
+ ));
+ return message("tool-assistant", MessageRole.ASSISTANT, MessageKind.TOOL_CALL, content);
+ }
+
+ private static AgentMessage toolResult(String toolUseId) {
+ return message(
+ "tool-result",
+ MessageRole.TOOL_RESULT,
+ MessageKind.TOOL_RESULT,
+ List.of(new ToolResultContentBlock(toolUseId, TOOL_TOKEN, false))
+ );
+ }
+
+ private static AgentMessage message(
+ String id,
+ MessageRole role,
+ MessageKind kind,
+ List content
+ ) {
+ return new AgentMessage(
+ id,
+ role,
+ kind,
+ content,
+ Instant.EPOCH,
+ Optional.empty(),
+ Optional.empty()
+ );
+ }
+
+ private static ToolRegistrySnapshot toolRegistry() {
+ return new ToolRegistrySnapshot(List.of(new ToolDescriptor(
+ TOOL_NAME,
+ List.of(),
+ "Required compatibility probe. Call it with the exact requested value.",
+ new JsonSchema(Map.of(
+ "type", "object",
+ "properties", Map.of("value", Map.of(
+ "type", "string",
+ "enum", List.of(TOOL_TOKEN)
+ )),
+ "required", List.of("value"),
+ "additionalProperties", false
+ )),
+ true,
+ false
+ )));
+ }
+
+ private static List stream(
+ DefaultModelPort modelPort,
+ ContextSnapshot context,
+ ToolRegistrySnapshot tools,
+ String operation
+ ) {
+ return safely(operation, () -> {
+ try (AssistantEventStream stream = modelPort.stream(context, tools, () -> false)) {
+ return StreamSupport.stream(stream.spliterator(), false).toList();
+ }
+ });
+ }
+
+ private static ToolCallDelta completeToolCall(List events) {
+ return events.stream()
+ .filter(ToolCallDelta.class::isInstance)
+ .map(ToolCallDelta.class::cast)
+ .filter(ToolCallDelta::complete)
+ .reduce((first, second) -> second)
+ .orElseThrow(() -> new AssertionError("Tool-call chat emitted no complete tool call."));
+ }
+
+ private static String joinedThinking(List events) {
+ return events.stream()
+ .filter(ThinkingDelta.class::isInstance)
+ .map(ThinkingDelta.class::cast)
+ .map(ThinkingDelta::text)
+ .collect(Collectors.joining());
+ }
+
+ private static String joinedText(List events) {
+ return events.stream()
+ .filter(TextDelta.class::isInstance)
+ .map(TextDelta.class::cast)
+ .map(TextDelta::text)
+ .collect(Collectors.joining());
+ }
+
+ private static void assertCompleted(List events) {
+ assertThat(events.stream().anyMatch(AssistantDone.class::isInstance))
+ .as("provider stream emitted AssistantDone")
+ .isTrue();
+ }
+
+ private static T safely(String operation, UnsafeSupplier supplier) {
+ try {
+ return supplier.get();
+ } catch (RuntimeException error) {
+ throw new AssertionError(operation + " failed (" + error.getClass().getSimpleName() + ").");
+ }
+ }
+
+ @FunctionalInterface
+ private interface UnsafeSupplier {
+ T get();
+ }
+}
diff --git a/lypi-boot/src/test/java/cn/lypi/boot/ai/OpenAiCompatibleProviderLoginServiceTest.java b/lypi-boot/src/test/java/cn/lypi/boot/ai/OpenAiCompatibleProviderLoginServiceTest.java
new file mode 100644
index 00000000..b2044770
--- /dev/null
+++ b/lypi-boot/src/test/java/cn/lypi/boot/ai/OpenAiCompatibleProviderLoginServiceTest.java
@@ -0,0 +1,558 @@
+package cn.lypi.boot.ai;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import cn.lypi.ai.DefaultModelRegistry;
+import cn.lypi.ai.ProviderAdapterApiProvider;
+import cn.lypi.ai.RuntimeModelRegistry;
+import cn.lypi.ai.model.DiscoveredModel;
+import cn.lypi.ai.model.DiscoveredModelDefaults;
+import cn.lypi.ai.model.RemoteModelDiscoveryClient;
+import cn.lypi.ai.provider.ProviderRequest;
+import cn.lypi.ai.provider.RequestStyle;
+import cn.lypi.ai.provider.TransportMode;
+import cn.lypi.ai.provider.openai.OpenAiCompatibleProviderAdapter;
+import cn.lypi.ai.provider.openai.OpenAiProviderConfig;
+import cn.lypi.contracts.error.ModelProviderException;
+import cn.lypi.contracts.model.ApiStyle;
+import cn.lypi.contracts.model.CostProfile;
+import cn.lypi.contracts.model.ModelDescriptor;
+import cn.lypi.contracts.runtime.ProviderLoginResult;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFileAttributeView;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class OpenAiCompatibleProviderLoginServiceTest {
+ private static final String AUTH_KEY = "test-auth-key";
+
+ @TempDir
+ Path tempDir;
+
+ private HttpServer server;
+
+ @AfterEach
+ void stopServer() {
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ @Test
+ void persistsAndRegistersOnlyAfterModelDiscoverySucceeds() throws Exception {
+ AtomicReference authorization = new AtomicReference<>();
+ startServer(exchange -> {
+ authorization.set(exchange.getRequestHeaders().getFirst("Authorization"));
+ respond(exchange, 200, """
+ {"data":[
+ {
+ "id":"zeta",
+ "context_window":128000,
+ "max_output_tokens":16384,
+ "supports_reasoning":false,
+ "supports_image_input":false
+ },
+ {"id":"alpha"}
+ ]}
+ """);
+ });
+ RuntimeModelRegistry registry = new DefaultModelRegistry(List.of());
+ ProviderAdapterApiProvider dispatcher = new ProviderAdapterApiProvider(ApiStyle.OPENAI_COMPATIBLE, List.of());
+ Path home = tempDir.resolve("home");
+ OpenAiCompatibleProviderLoginService service = service(registry, dispatcher, home);
+
+ ProviderLoginResult result = service.register("zen", baseUrl().toString() + "/", AUTH_KEY);
+ Path storeFile = home.resolve(".ly-pi/login-providers.properties");
+
+ assertThat(("Bearer " + AUTH_KEY).equals(authorization.get())).isTrue();
+ assertThat(result.provider()).isEqualTo("zen");
+ assertThat(result.models()).extracting(ModelDescriptor::modelId).containsExactly("zeta", "alpha");
+ assertThat(result.models().get(0)).satisfies(model -> {
+ assertThat(model.provider()).isEqualTo("zen");
+ assertThat(model.baseUrl()).isEqualTo(baseUrl());
+ assertThat(model.contextWindow()).isEqualTo(128_000);
+ assertThat(model.maxOutputTokens()).isEqualTo(16_384);
+ assertThat(model.supportsThinking()).isFalse();
+ assertThat(model.supportsImageInput()).isFalse();
+ });
+ assertThat(result.models().get(1)).satisfies(model -> {
+ assertThat(model.provider()).isEqualTo("zen");
+ assertThat(model.contextWindow()).isEqualTo(256_000);
+ assertThat(model.maxOutputTokens()).isEqualTo(8_192);
+ assertThat(model.supportsThinking()).isTrue();
+ assertThat(model.supportsImageInput()).isTrue();
+ });
+ assertThat(registry.list()).containsExactlyElementsOf(result.models());
+ assertThat(storeFile).exists();
+ Properties stored = properties(storeFile);
+ assertThat(stored.stringPropertyNames())
+ .contains(
+ "lypi.ai.providers." + result.provider() + ".request-style",
+ "lypi.ai.providers." + result.provider() + ".fallback-request-style",
+ "lypi.ai.providers." + result.provider() + ".transport"
+ );
+ assertThat(stored.getProperty(
+ "lypi.ai.providers." + result.provider() + ".request-style"
+ )).isEqualTo("chat_completions");
+ assertThat(stored.getProperty(
+ "lypi.ai.providers." + result.provider() + ".fallback-request-style"
+ )).isEqualTo("chat_completions");
+ assertThat(stored.getProperty(
+ "lypi.ai.providers." + result.provider() + ".transport"
+ )).isEqualTo("sse");
+ assertThat(stored.getProperty(
+ "lypi.ai.providers." + result.provider()
+ + ".compat.requires-reasoning-content-on-assistant-messages"
+ )).isEqualTo("true");
+ assertThat(stored.stringPropertyNames())
+ .noneMatch(name -> name.contains(".models[") || name.endsWith("supports-thinking"));
+ assertThat(config(dispatcher, result.provider()).requestStyle()).isEqualTo(RequestStyle.CHAT_COMPLETIONS);
+ assertThat(config(dispatcher, result.provider()).fallbackRequestStyle()).isEqualTo(RequestStyle.CHAT_COMPLETIONS);
+ assertThat(config(dispatcher, result.provider()).transportMode()).isEqualTo(TransportMode.SSE);
+ assertThat(config(dispatcher, result.provider()).compat())
+ .containsEntry("requires-reasoning-content-on-assistant-messages", true);
+ assertThat(config(dispatcher, result.provider()).toString().contains(AUTH_KEY)).isFalse();
+ assertPrivateFile(storeFile);
+ }
+
+ @Test
+ void fallsBackToModelEndpointAndReplacesOnlyTheNamedProvider() throws Exception {
+ AtomicReference calls = new AtomicReference<>(0);
+ List requestedPaths = new CopyOnWriteArrayList<>();
+ startServer(exchange -> {
+ requestedPaths.add(exchange.getRequestURI().getPath());
+ if (exchange.getRequestURI().getPath().endsWith("/models")) {
+ respond(exchange, 404, "");
+ return;
+ }
+ int call = calls.updateAndGet(value -> value + 1);
+ String modelId = switch (call) {
+ case 1 -> "old-model";
+ case 2 -> "other-model";
+ default -> "new-model";
+ };
+ respond(exchange, 200, "{\"models\":[{\"id\":\"" + modelId + "\"}]}");
+ });
+ RuntimeModelRegistry registry = new DefaultModelRegistry(List.of());
+ ProviderAdapterApiProvider dispatcher = new ProviderAdapterApiProvider(ApiStyle.OPENAI_COMPATIBLE, List.of());
+ Path home = tempDir.resolve("home");
+ OpenAiCompatibleProviderLoginService service = service(registry, dispatcher, home);
+
+ ProviderLoginResult first = service.register("zen-a", baseUrl().toString(), AUTH_KEY);
+ OpenAiCompatibleProviderAdapter firstAdapter = adapter(dispatcher, first.provider());
+ ProviderLoginResult other = service.register("zen-b", baseUrl().toString(), AUTH_KEY);
+ OpenAiCompatibleProviderAdapter otherAdapter = adapter(dispatcher, other.provider());
+ String replacementKey = "replacement-auth-key";
+ ProviderLoginResult second = service.register("zen-a", baseUrl().toString(), replacementKey);
+
+ assertThat(second.provider()).isEqualTo(first.provider());
+ assertThat(registry.list())
+ .filteredOn(model -> model.provider().equals(first.provider()))
+ .extracting(ModelDescriptor::modelId)
+ .containsExactly("new-model");
+ assertThat(registry.list())
+ .filteredOn(model -> model.provider().equals(other.provider()))
+ .extracting(ModelDescriptor::modelId)
+ .containsExactly("other-model");
+ Properties stored = properties(home.resolve(".ly-pi/login-providers.properties"));
+ assertThat(stored.stringPropertyNames())
+ .anyMatch(name -> name.startsWith("lypi.ai.providers.zen-a."))
+ .anyMatch(name -> name.startsWith("lypi.ai.providers.zen-b."));
+ assertThat(stored.getProperty("lypi.ai.providers.zen-a.api-key")).isEqualTo(replacementKey);
+ assertThat(stored.getProperty("lypi.ai.providers.zen-b.api-key")).isEqualTo(AUTH_KEY);
+ assertThat(calls.get()).isEqualTo(3);
+ assertThat(requestedPaths).containsExactly(
+ "/v1/models", "/v1/model",
+ "/v1/models", "/v1/model",
+ "/v1/models", "/v1/model"
+ );
+ assertThat(adapter(dispatcher, second.provider())).isNotSameAs(firstAdapter);
+ assertThat(adapter(dispatcher, other.provider())).isSameAs(otherAdapter);
+ assertThat(config(dispatcher, second.provider()).apiKey()).isEqualTo(replacementKey);
+ assertThat(config(dispatcher, other.provider()).apiKey()).isEqualTo(AUTH_KEY);
+ }
+
+ @Test
+ void leavesFileAndRuntimeUnchangedWhenDiscoveryFails() throws Exception {
+ startServer(exchange -> respond(exchange, 401, ""));
+ RuntimeModelRegistry registry = new DefaultModelRegistry(List.of(existingModel()));
+ ProviderAdapterApiProvider dispatcher = new ProviderAdapterApiProvider(ApiStyle.OPENAI_COMPATIBLE, List.of());
+ Path home = tempDir.resolve("home");
+ OpenAiCompatibleProviderLoginService service = service(registry, dispatcher, home);
+
+ assertThatThrownBy(() -> service.register("zen", baseUrl().toString(), AUTH_KEY))
+ .isInstanceOf(ModelProviderException.class)
+ .hasMessageNotContaining(AUTH_KEY);
+
+ assertThat(registry.list()).containsExactly(existingModel());
+ assertThat(home.resolve(".ly-pi/login-providers.properties")).doesNotExist();
+ assertThat(adapterCount(dispatcher)).isZero();
+ }
+
+ @Test
+ void leavesFileAndRuntimeUnchangedWhenBothDiscoveryEndpointsHaveNoModels() throws Exception {
+ List requestedPaths = new CopyOnWriteArrayList<>();
+ startServer(exchange -> {
+ requestedPaths.add(exchange.getRequestURI().getPath());
+ respond(exchange, 200, "{\"data\":[]}");
+ });
+ RuntimeModelRegistry registry = new DefaultModelRegistry(List.of(existingModel()));
+ ProviderAdapterApiProvider dispatcher = new ProviderAdapterApiProvider(ApiStyle.OPENAI_COMPATIBLE, List.of());
+ Path home = tempDir.resolve("home");
+ OpenAiCompatibleProviderLoginService service = service(registry, dispatcher, home);
+
+ assertThatThrownBy(() -> service.register("zen", baseUrl().toString(), AUTH_KEY))
+ .isInstanceOf(ModelProviderException.class)
+ .hasMessageNotContaining(AUTH_KEY);
+
+ assertThat(requestedPaths).containsExactly("/v1/models", "/v1/model");
+ assertThat(registry.list()).containsExactly(existingModel());
+ assertThat(adapterCount(dispatcher)).isZero();
+ assertThat(home.resolve(".ly-pi/login-providers.properties")).doesNotExist();
+ }
+
+ @Test
+ void leavesExistingStateUntouchedWhenPersistenceFails() throws Exception {
+ RuntimeModelRegistry registry = new DefaultModelRegistry(List.of());
+ ProviderAdapterApiProvider dispatcher = new ProviderAdapterApiProvider(ApiStyle.OPENAI_COMPATIBLE, List.of());
+ Path home = tempDir.resolve("home");
+ Path storeFile = home.resolve(".ly-pi/login-providers.properties");
+ OpenAiCompatibleProviderLoginService initialService = service(
+ registry,
+ dispatcher,
+ fixedDiscovery("old-model"),
+ new LoginProviderPropertiesStore(home)
+ );
+ ProviderLoginResult initial = initialService.register(
+ "zen",
+ "https://old.example.test/v1",
+ "old-key"
+ );
+ String initialFile = Files.readString(storeFile);
+ OpenAiCompatibleProviderAdapter initialAdapter = adapter(dispatcher, "zen");
+
+ LoginProviderPropertiesStore failingStore = new LoginProviderPropertiesStore(home) {
+ @Override
+ public void save(String provider, URI baseUrl, String authKey) throws IOException {
+ throw new IOException("simulated persistence failure");
+ }
+ };
+ OpenAiCompatibleProviderLoginService service = service(
+ registry,
+ dispatcher,
+ fixedDiscovery("verified-model"),
+ failingStore
+ );
+
+ String sensitiveUrl = "https://example.test/private-path";
+
+ assertThatThrownBy(() -> service.register("zen", sensitiveUrl, AUTH_KEY))
+ .isInstanceOf(ModelProviderException.class)
+ .hasMessageNotContaining("zen")
+ .hasMessageNotContaining(sensitiveUrl)
+ .hasMessageNotContaining(AUTH_KEY);
+
+ assertThat(Files.readString(storeFile)).isEqualTo(initialFile);
+ assertThat(registry.list()).containsExactlyElementsOf(initial.models());
+ assertThat(adapter(dispatcher, "zen")).isSameAs(initialAdapter);
+ assertThat(adapterCount(dispatcher)).isOne();
+ }
+
+ @Test
+ void rejectsInvalidChannelNamesBeforeDiscoveryPersistenceOrRuntimeMutation() {
+ AtomicInteger discoveryCalls = new AtomicInteger();
+ RemoteModelDiscoveryClient discovery = new RemoteModelDiscoveryClient() {
+ @Override
+ public List discoverModels(
+ URI baseUrl,
+ String apiKey,
+ List paths,
+ java.time.Duration timeout
+ ) {
+ discoveryCalls.incrementAndGet();
+ return List.of(DiscoveredModel.idOnly("unexpected"));
+ }
+ };
+ RuntimeModelRegistry registry = new DefaultModelRegistry(List.of(existingModel()));
+ ProviderAdapterApiProvider dispatcher = new ProviderAdapterApiProvider(ApiStyle.OPENAI_COMPATIBLE, List.of());
+ Path home = tempDir.resolve("home");
+ OpenAiCompatibleProviderLoginService service = service(
+ registry,
+ dispatcher,
+ discovery,
+ new LoginProviderPropertiesStore(home)
+ );
+
+ for (String invalid : List.of("Zen", "with space", ".nested", "a/b", "a".repeat(65))) {
+ assertThatThrownBy(() -> service.register(invalid, "not-a-url", " "))
+ .isInstanceOfSatisfying(ModelProviderException.class, error ->
+ assertThat(error.errorId()).isEqualTo("provider.login_invalid_channel_name"))
+ .hasMessage("Provider channel name must match [a-z0-9][a-z0-9_-]{0,63}.")
+ .hasMessageNotContaining(invalid);
+ }
+ assertThatThrownBy(() -> service.register(" ", "not-a-url", " "))
+ .isInstanceOfSatisfying(ModelProviderException.class, error ->
+ assertThat(error.errorId()).isEqualTo("provider.login_invalid_channel_name"))
+ .hasMessage("Provider channel name must match [a-z0-9][a-z0-9_-]{0,63}.");
+ assertThatThrownBy(() -> service.register(null, "not-a-url", " "))
+ .isInstanceOfSatisfying(ModelProviderException.class, error ->
+ assertThat(error.errorId()).isEqualTo("provider.login_invalid_channel_name"));
+
+ assertThat(discoveryCalls).hasValue(0);
+ assertThat(registry.list()).containsExactly(existingModel());
+ assertThat(adapterCount(dispatcher)).isZero();
+ assertThat(home.resolve(".ly-pi/login-providers.properties")).doesNotExist();
+ }
+
+ @Test
+ void appliesInjectedDefaultsToIdOnlyDiscoveredModels() {
+ RuntimeModelRegistry registry = new DefaultModelRegistry(List.of());
+ ProviderAdapterApiProvider dispatcher = new ProviderAdapterApiProvider(ApiStyle.OPENAI_COMPATIBLE, List.of());
+ RemoteModelDiscoveryClient discovery = new RemoteModelDiscoveryClient() {
+ @Override
+ public List discoverModels(
+ URI baseUrl,
+ String apiKey,
+ List paths,
+ java.time.Duration timeout
+ ) {
+ return List.of(DiscoveredModel.idOnly("defaulted"));
+ }
+ };
+ DiscoveredModelDefaults defaults = new DiscoveredModelDefaults(
+ 192_000,
+ 12_288,
+ true,
+ false,
+ new CostProfile(java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, "USD"),
+ Map.of()
+ );
+ OpenAiCompatibleProviderLoginService service = new OpenAiCompatibleProviderLoginService(
+ discovery,
+ registry,
+ dispatcher,
+ new LoginProviderPropertiesStore(tempDir.resolve("home")),
+ defaults
+ );
+
+ ModelDescriptor descriptor = service.register(
+ "zen",
+ "https://example.test/v1",
+ AUTH_KEY
+ ).models().getFirst();
+
+ assertThat(descriptor.contextWindow()).isEqualTo(192_000);
+ assertThat(descriptor.maxOutputTokens()).isEqualTo(12_288);
+ assertThat(descriptor.supportsThinking()).isTrue();
+ assertThat(descriptor.supportsImageInput()).isFalse();
+ }
+
+ @Test
+ void rejectsInvalidUrlAndBlankKeyBeforeMutatingRuntime() {
+ RuntimeModelRegistry registry = new DefaultModelRegistry(List.of(existingModel()));
+ ProviderAdapterApiProvider dispatcher = new ProviderAdapterApiProvider(ApiStyle.OPENAI_COMPATIBLE, List.of());
+ OpenAiCompatibleProviderLoginService service = service(registry, dispatcher, tempDir.resolve("home"));
+
+ for (String invalidUrl : List.of(
+ "https://user:pass@example.test/v1",
+ "https://example.test/v1?tenant=test",
+ "https://example.test/v1#fragment",
+ "ftp://example.test/v1"
+ )) {
+ assertThatThrownBy(() -> service.register("zen", invalidUrl, AUTH_KEY))
+ .isInstanceOf(ModelProviderException.class)
+ .hasMessageNotContaining(invalidUrl);
+ }
+ assertThatThrownBy(() -> service.register("zen", "https://example.test/v1", " "))
+ .isInstanceOf(ModelProviderException.class)
+ .hasMessageNotContaining(AUTH_KEY);
+
+ assertThat(registry.list()).containsExactly(existingModel());
+ assertThat(adapterCount(dispatcher)).isZero();
+ }
+
+ @Test
+ void redactsAuthKeyFromDiscoveryFailuresAndProviderRequestStrings() {
+ RuntimeModelRegistry registry = new DefaultModelRegistry(List.of(existingModel()));
+ ProviderAdapterApiProvider dispatcher = new ProviderAdapterApiProvider(ApiStyle.OPENAI_COMPATIBLE, List.of());
+ RemoteModelDiscoveryClient unsafeDiscovery = new RemoteModelDiscoveryClient() {
+ @Override
+ public List discoverModels(
+ URI baseUrl,
+ String apiKey,
+ List paths,
+ java.time.Duration timeout
+ ) {
+ throw new ModelProviderException(
+ "test.unsafe_discovery",
+ cn.lypi.contracts.error.ErrorSeverity.ERROR,
+ false,
+ "unsafe discovery " + apiKey
+ );
+ }
+ };
+ OpenAiCompatibleProviderLoginService service = service(
+ registry,
+ dispatcher,
+ unsafeDiscovery,
+ new LoginProviderPropertiesStore(tempDir.resolve("home"))
+ );
+ ProviderRequest request = new ProviderRequest(
+ URI.create("https://example.test/v1/chat/completions"),
+ Map.of("Authorization", "Bearer " + AUTH_KEY),
+ "{}"
+ );
+
+ assertThatThrownBy(() -> service.register("zen", "https://example.test/v1", AUTH_KEY))
+ .isInstanceOf(ModelProviderException.class)
+ .hasMessageNotContaining(AUTH_KEY);
+ assertThat(request.toString().contains(AUTH_KEY)).isFalse();
+ assertThat(registry.list()).containsExactly(existingModel());
+ assertThat(adapterCount(dispatcher)).isZero();
+ }
+
+ private OpenAiCompatibleProviderLoginService service(
+ RuntimeModelRegistry registry,
+ ProviderAdapterApiProvider dispatcher,
+ Path home
+ ) {
+ return service(registry, dispatcher, new RemoteModelDiscoveryClient(), new LoginProviderPropertiesStore(home));
+ }
+
+ private OpenAiCompatibleProviderLoginService service(
+ RuntimeModelRegistry registry,
+ ProviderAdapterApiProvider dispatcher,
+ RemoteModelDiscoveryClient discovery,
+ LoginProviderPropertiesStore store
+ ) {
+ return new OpenAiCompatibleProviderLoginService(discovery, registry, dispatcher, store, defaults());
+ }
+
+ private static DiscoveredModelDefaults defaults() {
+ return new DiscoveredModelDefaults(
+ DiscoveredModelDefaults.DEFAULT_CONTEXT_WINDOW,
+ DiscoveredModelDefaults.DEFAULT_MAX_OUTPUT_TOKENS,
+ DiscoveredModelDefaults.DEFAULT_SUPPORTS_THINKING,
+ DiscoveredModelDefaults.DEFAULT_SUPPORTS_IMAGE_INPUT,
+ new CostProfile(java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, "USD"),
+ Map.of()
+ );
+ }
+
+ private static RemoteModelDiscoveryClient fixedDiscovery(String modelId) {
+ return new RemoteModelDiscoveryClient() {
+ @Override
+ public List discoverModels(
+ URI baseUrl,
+ String apiKey,
+ List paths,
+ java.time.Duration timeout
+ ) {
+ return List.of(DiscoveredModel.idOnly(modelId));
+ }
+ };
+ }
+
+ private URI baseUrl() {
+ return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/v1");
+ }
+
+ private void startServer(ExchangeHandler handler) throws IOException {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/v1/models", handler::handle);
+ server.createContext("/v1/model", handler::handle);
+ server.start();
+ }
+
+ private static Properties properties(Path path) throws IOException {
+ Properties properties = new Properties();
+ try (java.io.InputStream input = Files.newInputStream(path)) {
+ properties.load(input);
+ }
+ return properties;
+ }
+
+ private static OpenAiProviderConfig config(ProviderAdapterApiProvider dispatcher, String provider) {
+ try {
+ OpenAiCompatibleProviderAdapter adapter = adapter(dispatcher, provider);
+ Field configField = OpenAiCompatibleProviderAdapter.class.getDeclaredField("config");
+ configField.setAccessible(true);
+ return (OpenAiProviderConfig) configField.get(adapter);
+ } catch (ReflectiveOperationException error) {
+ throw new AssertionError("Unable to inspect registered provider adapter", error);
+ }
+ }
+
+ private static OpenAiCompatibleProviderAdapter adapter(ProviderAdapterApiProvider dispatcher, String provider) {
+ return (OpenAiCompatibleProviderAdapter) adapters(dispatcher).get(provider);
+ }
+
+ private static int adapterCount(ProviderAdapterApiProvider dispatcher) {
+ return adapters(dispatcher).size();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map adapters(ProviderAdapterApiProvider dispatcher) {
+ try {
+ Field adaptersField = ProviderAdapterApiProvider.class.getDeclaredField("adapters");
+ adaptersField.setAccessible(true);
+ java.util.concurrent.atomic.AtomicReference