| 1 | using System.Net.Http.Json; |
| 2 | using System.Runtime.CompilerServices; |
| 3 | using System.Text.Json; |
| 4 | using CascadeIDE.Services.Fm; |
| 5 | |
| 6 | namespace CascadeIDE.Services; |
| 7 | |
| 8 | /// <summary>Провайдер чата через OpenAI-совместимый API (OpenAI, DeepSeek, Cloud.ru FM и др.).</summary> |
| 9 | public sealed class OpenAiCompatibleProvider : IAiChatProvider |
| 10 | { |
| 11 | private readonly HttpClient _httpClient; |
| 12 | private readonly string _apiKey; |
| 13 | private readonly string _modelId; |
| 14 | |
| 15 | public OpenAiCompatibleProvider(string baseUrl, string apiKey, string modelId) |
| 16 | { |
| 17 | var baseUri = new Uri(NormalizeBaseUrl(baseUrl)); |
| 18 | _httpClient = new HttpClient { BaseAddress = baseUri }; |
| 19 | _apiKey = apiKey ?? ""; |
| 20 | _modelId = modelId ?? "gpt-4o"; |
| 21 | _httpClient.DefaultRequestHeaders.Add("Authorization", "Bearer " + _apiKey); |
| 22 | } |
| 23 | |
| 24 | public async IAsyncEnumerable<string> StreamChatAsync( |
| 25 | string model, |
| 26 | IReadOnlyList<ChatMessage> messages, |
| 27 | [EnumeratorCancellation] CancellationToken cancellationToken = default, |
| 28 | ChatTurnUsageCollector? usageCollector = null) |
| 29 | { |
| 30 | var modelId = string.IsNullOrEmpty(model) ? _modelId : model; |
| 31 | if (string.IsNullOrEmpty(_apiKey)) |
| 32 | { |
| 33 | yield return "[Error: API key not set.]"; |
| 34 | yield break; |
| 35 | } |
| 36 | |
| 37 | var requestBody = new |
| 38 | { |
| 39 | model = modelId, |
| 40 | stream = true, |
| 41 | stream_options = new { include_usage = true }, |
| 42 | messages = messages.Select(m => new { role = m.Role, content = m.Content }).ToArray(), |
| 43 | }; |
| 44 | |
| 45 | using var request = new HttpRequestMessage(HttpMethod.Post, "v1/chat/completions"); |
| 46 | request.Content = JsonContent.Create(requestBody); |
| 47 | |
| 48 | using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); |
| 49 | if (!response.IsSuccessStatusCode) |
| 50 | { |
| 51 | var err = await response.Content.ReadAsStringAsync(cancellationToken); |
| 52 | yield return $"[API error {(int)response.StatusCode}: {err}]"; |
| 53 | yield break; |
| 54 | } |
| 55 | |
| 56 | FmTurnUsage? usage = null; |
| 57 | await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); |
| 58 | using var reader = new StreamReader(stream); |
| 59 | while (await reader.ReadLineAsync(cancellationToken) is { } line) |
| 60 | { |
| 61 | if (!line.StartsWith("data:", StringComparison.Ordinal)) continue; |
| 62 | var json = line.Length > 5 ? line[5..].Trim() : ""; |
| 63 | if (json == "[DONE]" || string.IsNullOrEmpty(json)) continue; |
| 64 | |
| 65 | var parsedUsage = FmOpenAiUsageParser.TryParseFromCompletionChunk(json); |
| 66 | if (parsedUsage is not null) |
| 67 | usage = parsedUsage; |
| 68 | |
| 69 | string? text = null; |
| 70 | try |
| 71 | { |
| 72 | using var doc = JsonDocument.Parse(json); |
| 73 | var root = doc.RootElement; |
| 74 | if (root.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0 |
| 75 | && choices[0].TryGetProperty("delta", out var delta) |
| 76 | && delta.TryGetProperty("content", out var contentEl)) |
| 77 | text = contentEl.GetString(); |
| 78 | } |
| 79 | catch |
| 80 | { |
| 81 | // skip invalid line |
| 82 | } |
| 83 | |
| 84 | if (!string.IsNullOrEmpty(text)) |
| 85 | yield return text; |
| 86 | } |
| 87 | |
| 88 | if (usage is not null) |
| 89 | usageCollector?.Report(usage); |
| 90 | } |
| 91 | |
| 92 | private static string NormalizeBaseUrl(string? baseUrl) |
| 93 | { |
| 94 | var t = (baseUrl ?? "").Trim().TrimEnd('/'); |
| 95 | if (t.Length == 0) |
| 96 | return "https://api.openai.com/v1/"; |
| 97 | if (!t.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) |
| 98 | t += "/v1"; |
| 99 | return t + "/"; |
| 100 | } |
| 101 | } |
| 102 | |