Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Moq;
using TelegramSearchBot.Interface;
using TelegramSearchBot.Model.AI;
using TelegramSearchBot.Model.Data;
using TelegramSearchBot.Service.AI.LLM;
using Xunit;

namespace TelegramSearchBot.LLM.Test.Service.AI.LLM {
public class AnthropicModelDiscoveryTests {
private static readonly string[] ExpectedStaticModels = {
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307"
};

[Theory]
[InlineData("https://api.anthropic.com", "https://api.anthropic.com/v1")]
[InlineData("https://api.anthropic.com/", "https://api.anthropic.com/v1")]
[InlineData("https://api.anthropic.com/v1", "https://api.anthropic.com/v1")]
[InlineData("https://api.anthropic.com/v1/", "https://api.anthropic.com/v1")]
[InlineData("https://opencode.ai/zen/v1", "https://opencode.ai/zen/v1")]
[InlineData("https://opencode.ai/zen/go/v1/", "https://opencode.ai/zen/go/v1")]
public void BuildModelsBaseUrl_NormalizesV1ExactlyOnce(string gateway, string expected) {
Assert.Equal(expected, AnthropicModelApi.BuildModelsBaseUrl(gateway));
}

[Fact]
public async Task GetAllModels_UsesDiscoveredModelsAndSendsAuthHeaders() {
var handler = new StubHandler(HttpStatusCode.OK, "{\"data\":[{\"id\":\"claude-opus-5\"},{\"id\":\"claude-sonnet-5\"}]}");
var service = CreateService(handler);

var models = (await service.GetAllModels(CreateChannel("https://api.anthropic.com"))).ToArray();

Assert.Equal(new[] { "claude-opus-5", "claude-sonnet-5" }, models);
Assert.Equal("https://api.anthropic.com/v1/models?limit=100", handler.RequestUri?.AbsoluteUri);
Assert.Equal("test-key", handler.LastRequest?.Headers.GetValues("x-api-key").Single());
Assert.Equal("2023-06-01", handler.LastRequest?.Headers.GetValues("anthropic-version").Single());
}

[Theory]
[InlineData(HttpStatusCode.InternalServerError, "upstream down")]
[InlineData(HttpStatusCode.OK, "{\"data\":[]}")]
[InlineData(HttpStatusCode.OK, "not json at all")]
public async Task GetAllModels_FallsBackToStaticSnapshot_WhenDiscoveryUnavailable(HttpStatusCode statusCode, string content) {
var handler = new StubHandler(statusCode, content);
var service = CreateService(handler);

var models = (await service.GetAllModels(CreateChannel("https://api.anthropic.com"))).ToArray();

Assert.Equal(ExpectedStaticModels, models);
}

[Fact]
public async Task GetAllModelsWithCapabilities_CoversDiscoveredModels() {
var handler = new StubHandler(HttpStatusCode.OK, "{\"data\":[{\"id\":\"claude-opus-5\"}]}");
var service = CreateService(handler);

var models = (await service.GetAllModelsWithCapabilities(CreateChannel("https://api.anthropic.com"))).ToArray();

var model = Assert.Single(models);
Assert.Equal("claude-opus-5", model.ModelName);
Assert.True(model.SupportsVision);
}

private static AnthropicModelApi CreateService(StubHandler handler) {
var factory = new Mock<IHttpClientFactory>();
factory.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(() => new HttpClient(handler, false));
return new AnthropicModelApi(
null!,
Mock.Of<ILogger<AnthropicModelApi>>(),
Mock.Of<IMessageExtensionService>(),
factory.Object);
}

private static LLMChannel CreateChannel(string gateway) {
return new LLMChannel {
Provider = LLMProvider.Anthropic,
Gateway = gateway,
ApiKey = "test-key"
};
}

private sealed class StubHandler : HttpMessageHandler {
private readonly HttpStatusCode _statusCode;
private readonly string _content;

public StubHandler(HttpStatusCode statusCode, string content) {
_statusCode = statusCode;
_content = content;
}

public Uri? RequestUri { get; private set; }
public HttpRequestMessage? LastRequest { get; private set; }

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
RequestUri = request.RequestUri;
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(_statusCode) {
Content = new StringContent(_content)
});
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -208,16 +208,72 @@ private void LogPromptCachingObservation(

#region Models

public virtual Task<IEnumerable<string>> GetAllModels(LLMChannel channel) {
return Task.FromResult<IEnumerable<string>>(_anthropicModels);
public virtual async Task<IEnumerable<string>> GetAllModels(LLMChannel channel) {
var discovered = await TryDiscoverModelsAsync(channel);
if (discovered.Count > 0) {
return discovered;
}

_logger.LogInformation("Anthropic 目录发现失败或为空,回退到内置静态快照({Count} 个模型)", _anthropicModels.Length);
return _anthropicModels;
}

/// <summary>
/// Anthropic-compatible GET /v1/models 发现(官方 API 与 OpenCode 等兼容网关都提供);
/// 任何失败都返回空列表,由调用方回退到静态快照,刷新链条不会因单个网关不可用而中断。
/// </summary>
private async Task<List<string>> TryDiscoverModelsAsync(LLMChannel channel) {
var result = new List<string>();
if (channel == null || string.IsNullOrWhiteSpace(channel.Gateway)) {
return result;
}

try {
var baseUrl = BuildModelsBaseUrl(channel.Gateway);
using var httpClient = _httpClientFactory.CreateClient();
if (!string.IsNullOrWhiteSpace(channel.ApiKey)) {
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("x-api-key", channel.ApiKey);
}
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("anthropic-version", "2023-06-01");
OpencodeSessionHeaders.Apply(httpClient, channel.Gateway);

using var response = await httpClient.GetAsync($"{baseUrl}/models?limit=100");
if (!response.IsSuccessStatusCode) {
_logger.LogWarning("Anthropic 目录请求失败 {StatusCode} ({Url})", (int)response.StatusCode, baseUrl);
return result;
}

var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array) {
return result;
}

foreach (var item in data.EnumerateArray()) {
var id = item.TryGetProperty("id", out var idProperty) ? idProperty.GetString() : null;
if (!string.IsNullOrWhiteSpace(id)) {
result.Add(id!);
}
}
} catch (Exception ex) {
_logger.LogWarning(ex, "Anthropic 目录请求异常,回退到静态快照");
}

return result;
}

/// <summary>把渠道网关归一化为 GET /v1/models 的基地址(重复的 /v1 只保留一次)。</summary>
internal static string BuildModelsBaseUrl(string gateway) {
var trimmed = gateway.TrimEnd('/');
return trimmed.EndsWith("/v1", StringComparison.OrdinalIgnoreCase) ? trimmed : trimmed + "/v1";
}

public virtual Task<IEnumerable<ModelWithCapabilities>> GetAllModelsWithCapabilities(LLMChannel channel) {
public virtual async Task<IEnumerable<ModelWithCapabilities>> GetAllModelsWithCapabilities(LLMChannel channel) {
var results = new List<ModelWithCapabilities>();
foreach (var modelName in _anthropicModels) {
foreach (var modelName in await GetAllModels(channel)) {
results.Add(InferAnthropicModelCapabilities(modelName));
}
return Task.FromResult<IEnumerable<ModelWithCapabilities>>(results);
return results;
}

private ModelWithCapabilities InferAnthropicModelCapabilities(string modelName) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Moq;
using TelegramSearchBot.Interface.Manage;
using TelegramSearchBot.Service.Scheduler;
using Xunit;

namespace TelegramSearchBot.Test.Service.Scheduler {
public class ModelCatalogRefreshTaskTests {
[Fact]
public async Task ExecuteAsync_RefreshesAllChannels_AndHeartbeats() {
var helper = new Mock<IEditLLMConfHelper>();
helper.Setup(h => h.RefreshAllChannel()).ReturnsAsync(3);

var services = new ServiceCollection();
services.AddSingleton(helper.Object);
services.AddLogging();
using var provider = services.BuildServiceProvider();

var task = new ModelCatalogRefreshTask(provider, provider.GetRequiredService<ILogger<ModelCatalogRefreshTask>>());
var heartbeats = 0;
task.SetHeartbeatCallback(() => {
heartbeats++;
return Task.CompletedTask;
});

await task.ExecuteAsync();

helper.Verify(h => h.RefreshAllChannel(), Times.Once);
Assert.Equal(2, heartbeats);
}

[Fact]
public void TaskName_AndCron_AreStable() {
var task = new ModelCatalogRefreshTask(Mock.Of<IServiceProvider>(), Mock.Of<ILogger<ModelCatalogRefreshTask>>());

Assert.Equal("ModelCatalogRefresh", task.TaskName);
Assert.Equal("0 */6 * * *", task.CronExpression);
}

[Fact]
public async Task ExecuteAsync_WithoutHeartbeatCallback_DoesNotThrow() {
var helper = new Mock<IEditLLMConfHelper>();
helper.Setup(h => h.RefreshAllChannel()).ReturnsAsync(0);

var services = new ServiceCollection();
services.AddSingleton(helper.Object);
services.AddLogging();
using var provider = services.BuildServiceProvider();

var task = new ModelCatalogRefreshTask(provider, provider.GetRequiredService<ILogger<ModelCatalogRefreshTask>>());

await task.ExecuteAsync();

helper.Verify(h => h.RefreshAllChannel(), Times.Once);
}
}
}
Loading
Loading