Forge
csharpcf25d736
1using System.Collections.Concurrent;
2using System.Net.Http.Headers;
3using System.Net.Http.Json;
4using System.Text.Json;
5
6namespace CascadeIDE.Services.Fm;
7
8/// <summary>Кэш <c>GET /v1/models</c> для OpenAI-compatible FM (Cloud.ru и др.).</summary>
9public sealed class FmModelCatalog
10{
11 private static readonly TimeSpan DefaultTtl = TimeSpan.FromHours(1);
12 private static readonly ConcurrentDictionary<string, CacheEntry> Cache = new(StringComparer.OrdinalIgnoreCase);
13
14 public async Task<FmModelInfo?> TryResolveModelAsync(
15 string baseUrl,
16 string apiKey,
17 string modelId,
18 CancellationToken cancellationToken = default)
19 {
20 var model = (modelId ?? "").Trim();
21 if (model.Length == 0 || string.IsNullOrWhiteSpace(apiKey))
22 return null;
23
24 var catalog = await TryLoadCatalogAsync(baseUrl, apiKey, cancellationToken).ConfigureAwait(false);
25 return catalog?.FirstOrDefault(m => string.Equals(m.ModelId, model, StringComparison.OrdinalIgnoreCase));
26 }
27
28 public async Task<IReadOnlyList<FmModelInfo>?> TryLoadCatalogAsync(
29 string baseUrl,
30 string apiKey,
31 CancellationToken cancellationToken = default)
32 {
33 if (string.IsNullOrWhiteSpace(apiKey))
34 return null;
35
36 var cacheKey = BuildCacheKey(baseUrl, apiKey);
37 if (Cache.TryGetValue(cacheKey, out var cached) && cached.ExpiresUtc > DateTimeOffset.UtcNow)
38 return cached.Models;
39
40 try
41 {
42 var endpoint = NormalizeBaseUrl(baseUrl);
43 using var client = new HttpClient { BaseAddress = new Uri(endpoint) };
44 client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
45
46 using var response = await client.GetAsync("v1/models", cancellationToken).ConfigureAwait(false);
47 if (!response.IsSuccessStatusCode)
48 return cached?.Models;
49
50 await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
51 using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
52 var models = ParseModelList(doc.RootElement);
53 Cache[cacheKey] = new CacheEntry(models, DateTimeOffset.UtcNow.Add(DefaultTtl));
54 return models;
55 }
56 catch
57 {
58 return cached?.Models;
59 }
60 }
61
62 internal static IReadOnlyList<FmModelInfo> ParseModelList(JsonElement root)
63 {
64 if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
65 return [];
66
67 var list = new List<FmModelInfo>();
68 foreach (var item in data.EnumerateArray())
69 {
70 if (!item.TryGetProperty("id", out var idEl) || idEl.ValueKind != JsonValueKind.String)
71 continue;
72
73 var id = idEl.GetString()?.Trim();
74 if (string.IsNullOrEmpty(id))
75 continue;
76
77 int? maxLen = null;
78 if (item.TryGetProperty("max_model_len", out var maxEl) && maxEl.TryGetInt32(out var max))
79 maxLen = max;
80
81 double? promptCost = null;
82 double? genCost = null;
83 if (item.TryGetProperty("metadata", out var meta) && meta.ValueKind == JsonValueKind.Object)
84 {
85 if (meta.TryGetProperty("prompt_tokens_cost", out var pc) && pc.TryGetDouble(out var pcd))
86 promptCost = pcd;
87 if (meta.TryGetProperty("generated_tokens_cost", out var gc) && gc.TryGetDouble(out var gcd))
88 genCost = gcd;
89 }
90
91 list.Add(new FmModelInfo(id, maxLen, promptCost, genCost));
92 }
93
94 return list;
95 }
96
97 private static string NormalizeBaseUrl(string baseUrl)
98 {
99 var t = (baseUrl ?? "").Trim().TrimEnd('/');
100 if (t.Length == 0)
101 t = "https://api.openai.com";
102 if (!t.EndsWith("/v1", StringComparison.OrdinalIgnoreCase))
103 t += "/v1";
104 return t + "/";
105 }
106
107 private static string BuildCacheKey(string baseUrl, string apiKey)
108 {
109 var url = NormalizeBaseUrl(baseUrl);
110 var keyTail = apiKey.Trim();
111 keyTail = keyTail.Length <= 8 ? keyTail : keyTail[^8..];
112 return url + "|" + keyTail;
113 }
114
115 private sealed record CacheEntry(IReadOnlyList<FmModelInfo> Models, DateTimeOffset ExpiresUtc);
116}
117
View only · write via MCP/CIDE