Forge
csharpdeeb25a2
1using System.Net.Http.Json;
2using System.Runtime.CompilerServices;
3using System.Text.Json;
4using CascadeIDE.Services.Fm;
5
6namespace CascadeIDE.Services;
7
8/// <summary>Провайдер чата через OpenAI-совместимый API (OpenAI, DeepSeek, Cloud.ru FM и др.).</summary>
9public 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
View only · write via MCP/CIDE