Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1a211af
feat(ai): fail on unavailable remote model discovery (task 1/8)
lyfmt Aug 9, 2026
b080c27
feat(ai): make discovered models authoritative (task 2/8)
lyfmt Aug 9, 2026
7392bf4
test(ai): lock compatible channel to chat completions (task 3/8)
lyfmt Aug 9, 2026
61f37a5
feat(tui): expose model catalog through contracts (task 4/8)
lyfmt Aug 9, 2026
e392adc
feat(tui): add model picker state (task 5/8)
lyfmt Aug 9, 2026
2f7116a
feat(tui): select available models from slash popup (task 6/8)
lyfmt Aug 9, 2026
35292fa
test(ai): verify opencode compatible channel end to end (task 7/8)
lyfmt Aug 9, 2026
a8d75e4
feat(ai): support runtime provider registration
lyfmt Aug 9, 2026
c9a8f8f
feat(boot): persist verified provider logins
lyfmt Aug 9, 2026
4f4bac5
feat(boot): wire dynamic provider login
lyfmt Aug 9, 2026
ec4fde8
feat(tui): add masked provider login overlay
lyfmt Aug 9, 2026
6b1b7d2
feat(tui): register providers through login command
lyfmt Aug 9, 2026
9dd21c3
docs: explain provider login
lyfmt Aug 9, 2026
3f1b739
feat(ai): parse discovered model capabilities
lyfmt Aug 9, 2026
1fa3d9c
feat(ai): default incomplete discovered model metadata
lyfmt Aug 9, 2026
e948ae2
feat: register named compatible channels through login
lyfmt Aug 9, 2026
511578a
fix(ai): normalize compatible chat reasoning
lyfmt Aug 9, 2026
b61b41a
feat(ai): send images through chat completions
lyfmt Aug 9, 2026
0f89b3f
test(ai): verify real compatible login and chat
lyfmt Aug 9, 2026
4882799
docs: explain compatible channel defaults
lyfmt Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,成功后模型以 `<channelName>/<modelId>` 出现在 `/model`。同名再次登录表示替换该渠道;URL、凭据、模型发现和持久化任一步失败时,已有运行时渠道保持不变。

登录固定使用 OpenAI-compatible Chat Completions over SSE。系统会依次探测 `<base-url>/models` 和 `<base-url>/model`,仅在至少发现一个可用模型后才保存并注册 Provider;成功后不会自动切换当前会话模型。日常登录只发现模型目录,不逐模型发送收费能力探针;仓库中的显式真实 E2E 会验证 HIGH thinking、工具续轮和图片 Chat Completions。

登录数据仅写入受管文件 `<user-home>/.ly-pi/login-providers.properties`,不会改写用户维护的 `<user-home>/.ly-pi/application.yml`,也不缓存发现到的模型列表。应用重启时会重新发现模型,并重新应用当前全局默认值和静态同名完整描述覆盖。

Anthropic 适配负责 Messages 请求、SSE 事件归一化、tool call/result 映射和 usage 合并。当前版本不启用 Anthropic extended thinking:Anthropic 模型的 `supports-thinking` 应保持 `false`,作为默认模型时还需把 `lypi.runtime.thinking-level` 设为 `off`。

### 资源与记忆
Expand Down
36 changes: 31 additions & 5 deletions lypi-ai/src/main/java/cn/lypi/ai/DefaultModelRegistry.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModelDescriptor> descriptors;
public final class DefaultModelRegistry implements RuntimeModelRegistry {
private final AtomicReference<List<ModelDescriptor>> descriptors;

public DefaultModelRegistry(List<ModelDescriptor> descriptors) {
this.descriptors = List.copyOf(Objects.requireNonNull(descriptors, "descriptors"));
this.descriptors = new AtomicReference<>(immutableDescriptors(descriptors));
}

@Override
public List<ModelDescriptor> list() {
return descriptors;
return descriptors.get();
}

@Override
public Optional<ModelDescriptor> find(ModelSelection selection) {
Objects.requireNonNull(selection, "selection");
return descriptors.stream()
List<ModelDescriptor> 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<ModelDescriptor> replacement) {
String requiredProvider = Objects.requireNonNull(provider, "provider");
List<ModelDescriptor> 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<ModelDescriptor> 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<ModelDescriptor> immutableDescriptors(List<ModelDescriptor> descriptors) {
return List.copyOf(Objects.requireNonNull(descriptors, "descriptors"));
}
}
31 changes: 25 additions & 6 deletions lypi-ai/src/main/java/cn/lypi/ai/ProviderAdapterApiProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, ProviderAdapter> adapters;
private final AtomicReference<Map<String, ProviderAdapter>> adapters;

public ProviderAdapterApiProvider(ApiStyle apiStyle, List<? extends ProviderAdapter> adapters) {
this.apiStyle = Objects.requireNonNull(apiStyle, "apiStyle");
this.adapters = List.copyOf(Objects.requireNonNull(adapters, "adapters")).stream()
.collect(Collectors.toUnmodifiableMap(ProviderAdapter::provider, Function.identity()));
this.adapters = new AtomicReference<>(indexAdapters(adapters));
}

@Override
Expand Down Expand Up @@ -58,7 +57,7 @@ public AssistantEventStream stream(
Objects.requireNonNull(descriptor, "descriptor");
Objects.requireNonNull(options, "options");
Objects.requireNonNull(signal, "signal");
ProviderAdapter adapter = adapters.get(descriptor.provider());
ProviderAdapter adapter = adapters.get().get(descriptor.provider());
if (adapter == null) {
throw new ModelProviderException(
"provider.adapter_unavailable",
Expand All @@ -69,4 +68,24 @@ public AssistantEventStream stream(
}
return adapter.stream(context, descriptor, tools, options, signal);
}

public void replaceAdapter(ProviderAdapter adapter) {
ProviderAdapter requiredAdapter = Objects.requireNonNull(adapter, "adapter");
adapters.updateAndGet(current -> {
Map<String, ProviderAdapter> next = new LinkedHashMap<>(current);
next.put(requiredAdapter.provider(), requiredAdapter);
return Map.copyOf(next);
});
}

private static Map<String, ProviderAdapter> indexAdapters(List<? extends ProviderAdapter> adapters) {
Map<String, ProviderAdapter> indexed = new LinkedHashMap<>();
for (ProviderAdapter adapter : List.copyOf(Objects.requireNonNull(adapters, "adapters"))) {
ProviderAdapter previous = indexed.putIfAbsent(adapter.provider(), adapter);
if (previous != null) {
throw new IllegalArgumentException("Duplicate provider adapter: " + adapter.provider());
}
}
return Map.copyOf(indexed);
}
}
11 changes: 11 additions & 0 deletions lypi-ai/src/main/java/cn/lypi/ai/RuntimeModelRegistry.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package cn.lypi.ai;

import cn.lypi.contracts.model.ModelDescriptor;
import java.util.List;

/**
* Boot 装配层用于替换一个 provider 的运行时模型快照。
*/
public interface RuntimeModelRegistry extends ModelRegistry {
void replaceProvider(String provider, List<ModelDescriptor> descriptors);
}
32 changes: 32 additions & 0 deletions lypi-ai/src/main/java/cn/lypi/ai/model/DiscoveredModel.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package cn.lypi.ai.model;

import java.util.Optional;
import java.util.OptionalInt;

public record DiscoveredModel(
String modelId,
OptionalInt contextWindow,
OptionalInt maxOutputTokens,
Optional<Boolean> supportsThinking,
Optional<Boolean> supportsImageInput
) {
public DiscoveredModel {
if (modelId == null || modelId.isBlank()) {
throw new IllegalArgumentException("modelId is required");
}
contextWindow = contextWindow == null ? OptionalInt.empty() : contextWindow;
maxOutputTokens = maxOutputTokens == null ? OptionalInt.empty() : maxOutputTokens;
supportsThinking = supportsThinking == null ? Optional.empty() : supportsThinking;
supportsImageInput = supportsImageInput == null ? Optional.empty() : supportsImageInput;
}

public static DiscoveredModel idOnly(String modelId) {
return new DiscoveredModel(
modelId,
OptionalInt.empty(),
OptionalInt.empty(),
Optional.empty(),
Optional.empty()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package cn.lypi.ai.model;

import cn.lypi.contracts.model.CostProfile;
import java.util.Map;
import java.util.Objects;

public record DiscoveredModelDefaults(
int contextWindow,
int maxOutputTokens,
boolean supportsThinking,
boolean supportsImageInput,
CostProfile costProfile,
Map<String, Object> compat
) {
public static final int DEFAULT_CONTEXT_WINDOW = 256_000;
public static final int DEFAULT_MAX_OUTPUT_TOKENS = 8_192;
public static final boolean DEFAULT_SUPPORTS_THINKING = true;
public static final boolean DEFAULT_SUPPORTS_IMAGE_INPUT = true;

public DiscoveredModelDefaults {
if (contextWindow <= 0 || maxOutputTokens <= 0) {
throw new IllegalArgumentException("Model discovery default token limits must be positive.");
}
costProfile = Objects.requireNonNull(costProfile, "costProfile");
compat = Map.copyOf(Objects.requireNonNull(compat, "compat"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package cn.lypi.ai.model;

import cn.lypi.contracts.model.ApiStyle;
import cn.lypi.contracts.model.ModelDescriptor;
import java.net.URI;
import java.util.Objects;

public final class DiscoveredModelDescriptorMapper {
private final String provider;
private final URI baseUrl;
private final ApiStyle apiStyle;
private final DiscoveredModelDefaults defaults;

public DiscoveredModelDescriptorMapper(
String provider,
URI baseUrl,
ApiStyle apiStyle,
DiscoveredModelDefaults defaults
) {
this.provider = Objects.requireNonNull(provider, "provider");
this.baseUrl = Objects.requireNonNull(baseUrl, "baseUrl");
this.apiStyle = Objects.requireNonNull(apiStyle, "apiStyle");
this.defaults = Objects.requireNonNull(defaults, "defaults");
}

public ModelDescriptor map(DiscoveredModel discovered) {
Objects.requireNonNull(discovered, "discovered");
return new ModelDescriptor(
provider,
discovered.modelId(),
baseUrl,
apiStyle,
discovered.contextWindow().orElse(defaults.contextWindow()),
discovered.maxOutputTokens().orElse(defaults.maxOutputTokens()),
discovered.supportsThinking().orElse(defaults.supportsThinking()),
discovered.supportsImageInput().orElse(defaults.supportsImageInput()),
defaults.costProfile(),
CompatSanitizer.sanitize(defaults.compat())
);
}
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
package cn.lypi.ai.model;

import cn.lypi.contracts.model.ApiStyle;
import cn.lypi.contracts.model.CostProfile;
import cn.lypi.contracts.model.ModelDescriptor;
import java.net.URI;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;

public final class RemoteModelDescriptorSource implements ModelDescriptorSource {
private final boolean enabled;
private final String provider;
private final URI baseUrl;
private final ApiStyle apiStyle;
private final String apiKey;
private final List<String> discoveryPaths;
private final Duration timeout;
private final RemoteModelDiscoveryClient client;
private final DescriptorDefaults defaults;
private final DiscoveredModelDescriptorMapper mapper;

public RemoteModelDescriptorSource(
boolean enabled,
Expand All @@ -30,53 +25,24 @@ public RemoteModelDescriptorSource(
List<String> discoveryPaths,
Duration timeout,
RemoteModelDiscoveryClient client,
DescriptorDefaults defaults
DiscoveredModelDefaults defaults
) {
this.enabled = enabled;
this.provider = Objects.requireNonNull(provider, "provider");
this.baseUrl = Objects.requireNonNull(baseUrl, "baseUrl");
this.apiStyle = Objects.requireNonNull(apiStyle, "apiStyle");
this.apiKey = apiKey;
this.discoveryPaths = List.copyOf(Objects.requireNonNull(discoveryPaths, "discoveryPaths"));
this.timeout = timeout == null ? Duration.ofSeconds(30) : timeout;
this.client = Objects.requireNonNull(client, "client");
this.defaults = Objects.requireNonNull(defaults, "defaults");
this.mapper = new DiscoveredModelDescriptorMapper(provider, this.baseUrl, apiStyle, defaults);
}

@Override
public List<ModelDescriptor> list() {
if (!enabled) {
return List.of();
}
List<ModelDescriptor> descriptors = new ArrayList<>();
for (String modelId : client.discover(baseUrl, apiKey, discoveryPaths, timeout)) {
descriptors.add(new ModelDescriptor(
provider,
modelId,
baseUrl,
apiStyle,
defaults.contextWindow(),
defaults.maxOutputTokens(),
defaults.supportsThinking(),
defaults.supportsImageInput(),
defaults.costProfile(),
CompatSanitizer.sanitize(defaults.compat())
));
}
return descriptors;
}

public record DescriptorDefaults(
int contextWindow,
int maxOutputTokens,
boolean supportsThinking,
boolean supportsImageInput,
CostProfile costProfile,
Map<String, Object> compat
) {
public DescriptorDefaults {
Objects.requireNonNull(costProfile, "costProfile");
compat = Map.copyOf(Objects.requireNonNull(compat, "compat"));
}
return client.discoverModels(baseUrl, apiKey, discoveryPaths, timeout).stream()
.map(mapper::map)
.toList();
}
}
Loading
Loading