Files

1 line
361 KiB
JSON

{"repo": "aidotnet/opendeepwiki", "files": {"src/OpenDeepWiki/Agents/DeepSeekOpenAIChatClient.cs": {"6eb5222d25487362fcd411891fad9027f7840f96": {"t": 1785746184.1195955, "n": 36498, "h": "b9fc78da85ab3fbf", "c": "using System.Net.Http.Headers;\nusing System.Runtime.CompilerServices;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.Json.Nodes;\nusing System.Text.Json.Serialization;\nusing Microsoft.Extensions.AI;\n\nnamespace OpenDeepWiki.Agents;\n\npublic sealed class DeepSeekOpenAIChatClient : IChatClient\n{\n private const string DefaultEndpoint = \"https://api.deepseek.com/v1\";\n private const string DefaultPromptCacheKey = \"opendeepwiki\";\n\n private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)\n {\n DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull\n };\n\n private readonly string _model;\n private readonly string _apiKey;\n private readonly HttpClient _httpClient;\n private readonly AiRequestOptions _options;\n private readonly bool _disposeHttpClient;\n\n public DeepSeekOpenAIChatClient(\n string model,\n string? endpoint,\n string apiKey,\n HttpClient httpClient,\n AiRequestOptions options,\n bool disposeHttpClient = false)\n {\n _model = string.IsNullOrWhiteSpace(model)\n ? throw new ArgumentException(\"Model is required.\", nameof(model))\n : model;\n _apiKey = string.IsNullOrWhiteSpace(apiKey)\n ? throw new ArgumentException(\"API key is required.\", nameof(apiKey))\n : apiKey;\n _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));\n _options = options ?? throw new ArgumentNullException(nameof(options));\n _options.Endpoint = NormalizeEndpoint(endpoint);\n _disposeHttpClient = disposeHttpClient;\n }\n\n public async Task<ChatResponse> GetResponseAsync(\n IEnumerable<ChatMessage> messages,\n ChatOptions? options = null,\n CancellationToken cancellationToken = default)\n {\n using var request = CreateRequest(messages, options, stream: false);\n using var response = await _httpClient.SendAsync(request, cancellationToken);\n var content = await response.Content.ReadAsStringAsync(cancellationToken);\n EnsureSuccessStatusCode(response, content);\n\n var completion = JsonSerializer.Deserialize<ChatCompletionResponse>(content, JsonOptions)\n ?? throw new JsonException(\"DeepSeek returned an empty chat completion response.\");\n var choice = completion.Choices?.FirstOrDefault();\n var message = choice?.Message ?? new ChatMessageDto();\n var chatMessage = CreateAssistantMessage(message);\n\n return new ChatResponse(chatMessage)\n {\n ResponseId = completion.Id,\n ModelId = completion.Model ?? _model,\n CreatedAt = ConvertCreatedAt(completion.Created),\n FinishReason = MapFinishReason(choice?.FinishReason),\n Usage = MapUsage(completion.Usage),\n RawRepresentation = content\n };\n }\n\n public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(\n IEnumerable<ChatMessage> messages,\n ChatOptions? options = null,\n [EnumeratorCancellation] CancellationToken cancellationToken = default)\n {\n using var request = CreateRequest(messages, options, stream: true);\n using var response = await _httpClient.SendAsync(\n request,\n HttpCompletionOption.ResponseHeadersRead,\n cancellationToken);\n var errorContent = response.IsSuccessStatusCode\n ? null\n : await response.Content.ReadAsStringAsync(cancellationToken);\n EnsureSuccessStatusCode(response, errorContent);\n\n await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);\n var toolCalls = new Dictionary<int, StreamingToolCallBuilder>();\n var emittedToolCalls = false;\n string? responseId = null;\n string? modelId = null;\n DateTimeOffset? createdAt = null;\n var messageId = Guid.NewGuid().ToString(\"N\");\n\n await foreach (var payload in ReadSseDataAsync(stream, cancellationToken))\n {\n if (IsDonePayload(payload))\n {\n break;\n }\n\n ChatCompletionChunk chunk;\n try\n {\n chunk = JsonSerializer.Deserialize<ChatCompletionChunk>(payload, JsonOptions)\n ?? throw new JsonException(\"DeepSeek returned an empty stream chunk.\");\n }\n catch (JsonException ex)\n {\n throw new JsonException($\"Invalid DeepSeek stream data: {TrimForError(payload)}\", ex);\n }\n\n responseId ??= chunk.Id;\n modelId = chunk.Model ?? modelId ?? _model;\n createdAt ??= ConvertCreatedAt(chunk.Created);\n\n if (chunk.Usage != null)\n {\n yield return new ChatResponseUpdate\n {\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n Contents = [new UsageContent(MapUsage(chunk.Usage)!)],\n RawRepresentation = payload\n };\n }\n\n if (chunk.Choices is not { Count: > 0 })\n {\n continue;\n }\n\n foreach (var choice in chunk.Choices)\n {\n var delta = choice.Delta;\n if (!string.IsNullOrEmpty(delta?.Content))\n {\n yield return new ChatResponseUpdate(ChatRole.Assistant, delta.Content)\n {\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n RawRepresentation = payload\n };\n }\n\n if (!string.IsNullOrEmpty(delta?.ReasoningContent))\n {\n var reasoningContent = new TextReasoningContent(delta.ReasoningContent)\n {\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"reasoning_content\"] = delta.ReasoningContent\n }\n };\n var reasoningUpdate = new ChatResponseUpdate(ChatRole.Assistant, [reasoningContent])\n {\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n RawRepresentation = payload,\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"reasoning_content\"] = delta.ReasoningContent\n }\n };\n yield return reasoningUpdate;\n }\n\n AccumulateToolCalls(toolCalls, delta?.ToolCalls);\n\n var finishReason = MapFinishReason(choice.FinishReason);\n if (finishReason == ChatFinishReason.ToolCalls && toolCalls.Count > 0)\n {\n emittedToolCalls = true;\n yield return CreateToolCallsUpdate(\n toolCalls,\n responseId,\n messageId,\n modelId,\n createdAt,\n finishReason,\n payload);\n }\n else if (finishReason != null)\n {\n yield return new ChatResponseUpdate\n {\n Role = ChatRole.Assistant,\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n FinishReason = finishReason,\n RawRepresentation = payload\n };\n }\n }\n }\n\n if (!emittedToolCalls && toolCalls.Count > 0)\n {\n yield return CreateToolCallsUpdate(\n toolCalls,\n responseId,\n messageId,\n modelId ?? _model,\n createdAt,\n ChatFinishReason.ToolCalls,\n null);\n }\n }\n\n public object? GetService(Type serviceType, object? serviceKey = null)\n {\n return serviceKey == null && serviceType.IsInstanceOfType(this) ? this : null;\n }\n\n public void Dispose()\n {\n if (_disposeHttpClient)\n {\n _httpClient.Dispose();\n }\n }\n\n private HttpRequestMessage CreateRequest(\n IEnumerable<ChatMessage> messages,\n ChatOptions? options,\n bool stream)\n {\n var requestMessages = BuildMessages(messages);\n PrependInstructions(requestMessages, options?.Instructions);\n\n var body = new JsonObject\n {\n [\"model\"] = options?.ModelId ?? _model,\n [\"stream\"] = stream,\n [\"prompt_cache_key\"] = ResolvePromptCacheKey(options),\n [\"messages\"] = requestMessages\n };\n\n if (stream)\n {\n body[\"stream_options\"] = new JsonObject\n {\n [\"include_usage\"] = true\n };\n }\n\n ApplyCommonOptions(body, options);\n ApplyTools(body, options);\n ApplyRequestOverrides(body, null, _options.ProviderRequestOverridesJson);\n ApplyRequestOverrides(body, null, _options.ModelRequestOverridesJson);\n ApplyThinkingConfig(body, options);\n\n var request = new HttpRequestMessage(\n HttpMethod.Post,\n AppendEndpointPath(_options.Endpoint ?? DefaultEndpoint, \"chat/completions\"))\n {\n Content = new StringContent(body.ToJsonString(JsonOptions), Encoding.UTF8, \"application/json\")\n };\n\n request.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", _apiKey);\n request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(stream ? \"text/event-stream\" : \"application/json\"));\n ApplyRequestOverrides(body, request, _options.ProviderRequestOverridesJson);\n ApplyRequestOverrides(body, request, _options.ModelRequestOverridesJson);\n return request;\n }\n\n private static JsonArray BuildMessages(IEnumerable<ChatMessage> messages)\n {\n var array = new JsonArray();\n foreach (var message in messages)\n {\n var functionResults = message.Contents.OfType<FunctionResultContent>().ToList();\n if (functionResults.Count > 0)\n {\n foreach (var result in functionResults)\n {\n array.Add(new JsonObject\n {\n [\"role\"] = \"tool\",\n [\"tool_call_id\"] = result.CallId,\n [\"content\"] = SerializeToolResult(result.Result)\n });\n }\n\n continue;\n }\n\n var functionCalls = message.Contents.OfType<FunctionCallContent>().ToList();\n var role = ToOpenAIRole(message.Role);\n var node = new JsonObject\n {\n [\"role\"] = role\n };\n\n var text = GetText(message.Contents);\n node[\"content\"] = string.IsNullOrEmpty(text) && functionCalls.Count > 0 ? null : text;\n\n var reasoningContent = GetReasoningContent(message);\n if (role == \"assistant\" && !string.IsNullOrEmpty(reasoningContent))\n {\n node[\"reasoning_content\"] = reasoningContent;\n }\n\n if (functionCalls.Count > 0)\n {\n var toolCalls = new JsonArray();\n foreach (var call in functionCalls)\n {\n toolCalls.Add(new JsonObject\n {\n [\"id\"] = call.CallId,\n [\"type\"] = \"function\",\n [\"function\"] = new JsonObject\n {\n [\"name\"] = call.Name,\n [\"arguments\"] = JsonSerializer.Serialize(call.Arguments, JsonOptions)\n }\n });\n }\n\n node[\"tool_calls\"] = toolCalls;\n }\n\n array.Add(node);\n }\n\n return array;\n }\n\n private static void PrependInstructions(JsonArray messages, string? instructions)\n {\n if (string.IsNullOrWhiteSpace(instructions) || HasSystemMessage(messages))\n {\n return;\n }\n\n messages.Insert(0, new JsonObject\n {\n [\"role\"] = \"system\",\n [\"content\"] = instructions\n });\n }\n\n private static bool HasSystemMessage(JsonArray messages)\n {\n foreach (var message in messages)\n {\n if (message is JsonObject node &&\n node[\"role\"]?.GetValue<string>().Equals(\"system\", StringComparison.OrdinalIgnoreCase) == true)\n {\n return true;\n }\n }\n\n return false;\n }\n\n private static void ApplyCommonOptions(JsonObject body, ChatOptions? options)\n {\n if (options == null)\n {\n return;\n }\n\n if (options.MaxOutputTokens.HasValue)\n {\n body[\"max_tokens\"] = options.MaxOutputTokens.Value;\n }\n\n if (options.Temperature.HasValue)\n {\n body[\"temperature\"] = options.Temperature.Value;\n }\n\n if (options.TopP.HasValue)\n {\n body[\"top_p\"] = options.TopP.Value;\n }\n\n if (options.FrequencyPenalty.HasValue)\n {\n body[\"frequency_penalty\"] = options.FrequencyPenalty.Value;\n }\n\n if (options.PresencePenalty.HasValue)\n {\n body[\"presence_penalty\"] = options.PresencePenalty.Value;\n }\n\n if (options.Seed.HasValue)\n {\n body[\"seed\"] = options.Seed.Value;\n }\n\n if (options.StopSequences is { Count: > 0 })\n {\n var stops = new JsonArray();\n foreach (var stop in options.StopSequences)\n {\n stops.Add(stop);\n }\n\n body[\"stop\"] = stops;\n }\n }\n\n private static void ApplyTools(JsonObject body, ChatOptions? options)\n {\n if (options?.Tools is not { Count: > 0 })\n {\n return;\n }\n\n var tools = new JsonArray();\n foreach (var tool in options.Tools)\n {\n if (tool is not AIFunctionDeclaration function)\n {\n continue;\n }\n\n tools.Add(new JsonObject\n {\n [\"type\"] = \"function\",\n [\"function\"] = new JsonObject\n {\n [\"name\"] = function.Name,\n [\"description\"] = function.Description ?? string.Empty,\n [\"parameters\"] = CloneJsonElement(function.JsonSchema) ?? new JsonObject\n {\n [\"type\"] = \"object\"\n }\n }\n });\n }\n\n if (tools.Count == 0)\n {\n return;\n }\n\n body[\"tools\"] = tools;\n body[\"tool_choice\"] = options.ToolMode switch\n {\n NoneChatToolMode => JsonValue.Create(\"none\"),\n RequiredChatToolMode required when !string.IsNullOrWhiteSpace(required.RequiredFunctionName) => new JsonObject\n {\n [\"type\"] = \"function\",\n [\"function\"] = new JsonObject\n {\n [\"name\"] = required.RequiredFunctionName\n }\n },\n RequiredChatToolMode => JsonValue.Create(\"required\"),\n _ => JsonValue.Create(\"auto\")\n };\n }\n\n private void ApplyThinkingConfig(JsonObject body, ChatOptions? options)\n {\n if (!_options.SupportsThinking || string.IsNullOrWhiteSpace(_options.ThinkingConfigJson))\n {\n return;\n }\n\n var enabled = ResolveThinkingEnabled(options);\n if (enabled == null)\n {\n return;\n }\n\n JsonNode? parsed;\n try\n {\n parsed = JsonNode.Parse(_options.ThinkingConfigJson);\n }\n catch (JsonException)\n {\n return;\n }\n\n if (parsed is not JsonObject config)\n {\n return;\n }\n\n var bodyParamName = enabled.Value ? \"bodyParams\" : \"disabledBodyParams\";\n if (config[bodyParamName] is JsonObject bodyParams)\n {\n MergeJsonObject(body, bodyParams);\n }\n\n if (enabled.Value && config[\"forceTemperature\"] is JsonValue forceTemperature)\n {\n body[\"temperature\"] = forceTemperature.DeepClone();\n }\n }\n\n private bool? ResolveThinkingEnabled(ChatOptions? options)\n {\n if (TryReadBoolean(options?.AdditionalProperties, \"thinkingEnabled\", out var thinkingEnabled) ||\n TryReadBoolean(options?.AdditionalProperties, \"enableThinking\", out thinkingEnabled) ||\n TryReadBoolean(options?.AdditionalProperties, \"enable_thinking\", out thinkingEnabled))\n {\n return thinkingEnabled;\n }\n\n return _options.ThinkingEnabled ?? true;\n }\n\n private string ResolvePromptCacheKey(ChatOptions? options)\n {\n if (TryReadString(options?.AdditionalProperties, \"promptCacheKey\", out var promptCacheKey) ||\n TryReadString(options?.AdditionalProperties, \"prompt_cache_key\", out promptCacheKey))\n {\n return NormalizePromptCacheKey(promptCacheKey);\n }\n\n return NormalizePromptCacheKey(_options.PromptCacheKey);\n }\n\n private static string NormalizePromptCacheKey(string? promptCacheKey)\n {\n return string.IsNullOrWhiteSpace(promptCacheKey)\n ? DefaultPromptCacheKey\n : promptCacheKey.Trim();\n }\n\n private static void ApplyRequestOverrides(\n JsonObject body,\n HttpRequestMessage? request,\n string? overridesJson)\n {\n if (string.IsNullOrWhiteSpace(overridesJson))\n {\n return;\n }\n\n JsonNode? parsed;\n try\n {\n parsed = JsonNode.Parse(overridesJson);\n }\n catch (JsonException)\n {\n return;\n }\n\n if (parsed is not JsonObject overrides)\n {\n return;\n }\n\n if (request == null)\n {\n if (overrides[\"bodyParams\"] is JsonObject bodyParams)\n {\n MergeJsonObject(body, bodyParams);\n }\n\n if (overrides[\"body\"] is JsonObject bodyOverride)\n {\n MergeJsonObject(body, bodyOverride);\n }\n\n foreach (var pair in overrides)\n {\n if (pair.Key.Equals(\"headers\", StringComparison.OrdinalIgnoreCase) ||\n pair.Key.Equals(\"bodyParams\", StringComparison.OrdinalIgnoreCase) ||\n pair.Key.Equals(\"body\", StringComparison.OrdinalIgnoreCase))\n {\n continue;\n }\n\n body[pair.Key] = pair.Value?.DeepClone();\n }\n\n return;\n }\n\n if (overrides[\"headers\"] is not JsonObject headers)\n {\n return;\n }\n\n foreach (var header in headers)\n {\n if (header.Value == null)\n {\n continue;\n }\n\n var value = header.Value.GetValueKind() == JsonValueKind.String\n ? header.Value.GetValue<string>()\n : header.Value.ToJsonString(JsonOptions);\n if (string.IsNullOrWhiteSpace(value))\n {\n continue;\n }\n\n if (header.Key.Equals(\"Content-Type\", StringComparison.OrdinalIgnoreCase))\n {\n request.Content?.Headers.Remove(header.Key);\n request.Content?.Headers.TryAddWithoutValidation(header.Key, value);\n }\n else\n {\n request.Headers.Remove(header.Key);\n request.Headers.TryAddWithoutValidation(header.Key, value);\n }\n }\n }\n\n private static ChatMessage CreateAssistantMessage(ChatMessageDto message)\n {\n var contents = new List<AIContent>();\n if (!string.IsNullOrEmpty(message.ReasoningContent))\n {\n contents.Add(new TextReasoningContent(message.ReasoningContent)\n {\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"reasoning_content\"] = message.ReasoningContent\n }\n });\n }\n\n if (!string.IsNullOrEmpty(message.Content))\n {\n contents.Add(new TextContent(message.Content));\n }\n\n if (message.ToolCalls is { Count: > 0 })\n {\n foreach (var toolCall in message.ToolCalls)\n {\n var function = toolCall.Function;\n if (string.IsNullOrWhiteSpace(toolCall.Id) || string.IsNullOrWhiteSpace(function?.Name))\n {\n continue;\n }\n\n contents.Add(new FunctionCallContent(\n toolCall.Id,\n function.Name,\n ParseArguments(function.Arguments)));\n }\n }\n\n return new ChatMessage(ChatRole.Assistant, contents);\n }\n\n private static ChatResponseUpdate CreateToolCallsUpdate(\n Dictionary<int, StreamingToolCallBuilder> toolCalls,\n string? responseId,\n string messageId,\n string? modelId,\n DateTimeOffset? createdAt,\n ChatFinishReason? finishReason,\n string? rawPayload)\n {\n var contents = toolCalls\n .OrderBy(pair => pair.Key)\n .Select(pair => pair.Value)\n .Where(call => !string.IsNullOrWhiteSpace(call.Id) && !string.IsNullOrWhiteSpace(call.Name))\n .Select(call => (AIContent)new FunctionCallContent(\n call.Id!,\n call.Name!,\n ParseArguments(call.Arguments.ToString())))\n .ToList();\n\n return new ChatResponseUpdate(ChatRole.Assistant, contents)\n {\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n FinishReason = finishReason,\n RawRepresentation = rawPayload\n };\n }\n\n private static void AccumulateToolCalls(\n Dictionary<int, StreamingToolCallBuilder> toolCalls,\n IReadOnlyList<ToolCallDeltaDto>? deltas)\n {\n if (deltas is not { Count: > 0 })\n {\n return;\n }\n\n for (var position = 0; position < deltas.Count; position++)\n {\n var delta = deltas[position];\n var index = delta.Index ?? position;\n if (!toolCalls.TryGetValue(index, out var builder))\n {\n builder = new StreamingToolCallBuilder();\n toolCalls[index] = builder;\n }\n\n if (!string.IsNullOrWhiteSpace(delta.Id))\n {\n builder.Id = delta.Id;\n }\n\n if (!string.IsNullOrWhiteSpace(delta.Function?.Name))\n {\n builder.Name = delta.Function.Name;\n }\n\n if (!string.IsNullOrEmpty(delta.Function?.Arguments))\n {\n builder.Arguments.Append(delta.Function.Arguments);\n }\n }\n }\n\n private static async IAsyncEnumerable<string> ReadSseDataAsync(\n Stream stream,\n [EnumeratorCancellation] CancellationToken cancellationToken)\n {\n using var reader = new StreamReader(stream, Encoding.UTF8);\n var data = new StringBuilder();\n while (await reader.ReadLineAsync(cancellationToken) is { } line)\n {\n if (line.Length == 0)\n {\n if (data.Length > 0)\n {\n yield return data.ToString();\n data.Clear();\n }\n\n continue;\n }\n\n if (line.StartsWith(':'))\n {\n continue;\n }\n\n if (!line.StartsWith(\"data:\", StringComparison.OrdinalIgnoreCase))\n {\n continue;\n }\n\n if (data.Length > 0)\n {\n data.Append('\\n');\n }\n\n data.Append(line[5..].TrimStart());\n }\n\n if (data.Length > 0)\n {\n yield return data.ToString();\n }\n }\n\n private static UsageDetails? MapUsage(UsageDto? usage)\n {\n if (usage == null)\n {\n return null;\n }\n\n return new UsageDetails\n {\n InputTokenCount = usage.PromptTokens,\n OutputTokenCount = usage.CompletionTokens,\n TotalTokenCount = usage.TotalTokens,\n CachedInputTokenCount = usage.PromptTokensDetails?.CachedTokens,\n ReasoningTokenCount = usage.CompletionTokensDetails?.ReasoningTokens\n };\n }\n\n private static ChatFinishReason? MapFinishReason(string? finishReason)\n {\n return finishReason?.Trim().ToLowerInvariant() switch\n {\n \"stop\" => ChatFinishReason.Stop,\n \"length\" => ChatFinishReason.Length,\n \"tool_calls\" or \"function_call\" => ChatFinishReason.ToolCalls,\n \"content_filter\" => ChatFinishReason.ContentFilter,\n _ => null\n };\n }\n\n private static IDictionary<string, object?> ParseArguments(string? arguments)\n {\n if (string.IsNullOrWhiteSpace(arguments))\n {\n return new Dictionary<string, object?>();\n }\n\n try\n {\n using var document = JsonDocument.Parse(arguments);\n if (document.RootElement.ValueKind != JsonValueKind.Object)\n {\n return new Dictionary<string, object?>\n {\n [\"value\"] = ConvertJsonElement(document.RootElement)\n };\n }\n\n return document.RootElement.EnumerateObject()\n .ToDictionary(\n property => property.Name,\n property => ConvertJsonElement(property.Value));\n }\n catch (JsonException)\n {\n return new Dictionary<string, object?>\n {\n [\"value\"] = arguments\n };\n }\n }\n\n private static object? ConvertJsonElement(JsonElement element)\n {\n return element.ValueKind switch\n {\n JsonValueKind.Object => element.EnumerateObject()\n .ToDictionary(property => property.Name, property => ConvertJsonElement(property.Value)),\n JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonElement).ToList(),\n JsonValueKind.String => element.GetString(),\n JsonValueKind.Number when element.TryGetInt64(out var longValue) => longValue,\n JsonValueKind.Number when element.TryGetDouble(out var doubleValue) => doubleValue,\n JsonValueKind.True => true,\n JsonValueKind.False => false,\n JsonValueKind.Null => null,\n _ => element.GetRawText()\n };\n }\n\n private static string SerializeToolResult(object? result)\n {\n return result switch\n {\n null => \"null\",\n string text => text,\n JsonElement element => element.GetRawText(),\n JsonNode node => node.ToJsonString(JsonOptions),\n _ => JsonSerializer.Serialize(result, JsonOptions)\n };\n }\n\n private static string GetText(IEnumerable<AIContent> contents)\n {\n return string.Concat(contents.OfType<TextContent>().Select(content => content.Text));\n }\n\n private static string GetReasoningContent(ChatMessage message)\n {\n var reasoning = string.Concat(message.Contents\n .OfType<TextReasoningContent>()\n .Select(content => content.Text));\n if (!string.IsNullOrEmpty(reasoning))\n {\n return reasoning;\n }\n\n if (TryReadString(message.AdditionalProperties, \"reasoning_content\", out var rawReasoning))\n {\n return rawReasoning;\n }\n\n return string.Empty;\n }\n\n private static JsonNode? CloneJsonElement(JsonElement element)\n {\n return element.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null\n ? null\n : JsonNode.Parse(element.GetRawText());\n }\n\n private static void MergeJsonObject(JsonObject target, JsonObject source)\n {\n foreach (var pair in source)\n {\n target[pair.Key] = pair.Value?.DeepClone();\n }\n }\n\n private static bool TryReadBoolean(\n AdditionalPropertiesDictionary? properties,\n string key,\n out bool value)\n {\n value = false;\n if (properties == null || !properties.TryGetValue(key, out var raw))\n {\n return false;\n }\n\n switch (raw)\n {\n case bool boolValue:\n value = boolValue;\n return true;\n case string text when bool.TryParse(text, out var parsed):\n value = parsed;\n return true;\n default:\n return false;\n }\n }\n\n private static bool TryReadString(\n AdditionalPropertiesDictionary? properties,\n string key,\n out string value)\n {\n value = string.Empty;\n if (properties == null || !properties.TryGetValue(key, out var raw))\n {\n return false;\n }\n\n switch (raw)\n {\n case string text:\n value = text;\n return true;\n case JsonElement element when element.ValueKind == JsonValueKind.String:\n value = element.GetString() ?? string.Empty;\n return true;\n case JsonNode node when node.GetValueKind() == JsonValueKind.String:\n value = node.GetValue<string>();\n return true;\n default:\n return false;\n }\n }\n\n private static DateTimeOffset? ConvertCreatedAt(long? unixSeconds)\n {\n return unixSeconds.HasValue ? DateTimeOffset.FromUnixTimeSeconds(unixSeconds.Value) : null;\n }\n\n private static string ToOpenAIRole(ChatRole role)\n {\n if (role == ChatRole.System)\n {\n return \"system\";\n }\n\n if (role == ChatRole.Assistant)\n {\n return \"assistant\";\n }\n\n if (role == ChatRole.Tool)\n {\n return \"tool\";\n }\n\n return \"user\";\n }\n\n private static string NormalizeEndpoint(string? endpoint)\n {\n return string.IsNullOrWhiteSpace(endpoint)\n ? DefaultEndpoint\n : endpoint.TrimEnd('/');\n }\n\n private static string AppendEndpointPath(string baseUrl, string path)\n {\n var trimmed = baseUrl.TrimEnd('/');\n if (trimmed.EndsWith($\"/{path}\", StringComparison.OrdinalIgnoreCase))\n {\n return trimmed;\n }\n\n return $\"{trimmed}/{path}\";\n }\n\n private static bool IsDonePayload(string payload)\n {\n return payload.Equals(\"[DONE]\", StringComparison.OrdinalIgnoreCase) ||\n payload.Equals(\"DONE\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void EnsureSuccessStatusCode(HttpResponseMessage response, string? content)\n {\n if (response.IsSuccessStatusCode)\n {\n return;\n }\n\n var message = string.IsNullOrWhiteSpace(content)\n ? response.ReasonPhrase\n : TrimForError(content);\n throw new HttpRequestException(\n $\"DeepSeek OpenAI-compatible request failed with HTTP {(int)response.StatusCode}: {message}\",\n null,\n response.StatusCode);\n }\n\n private static string TrimForError(string text)\n {\n var trimmed = text.Trim();\n return trimmed.Length <= 600 ? trimmed : trimmed[..600];\n }\n\n private sealed class StreamingToolCallBuilder\n {\n public string? Id { get; set; }\n public string? Name { get; set; }\n public StringBuilder Arguments { get; } = new();\n }\n\n private sealed class ChatCompletionResponse\n {\n [JsonPropertyName(\"id\")]\n public string? Id { get; init; }\n\n [JsonPropertyName(\"model\")]\n public string? Model { get; init; }\n\n [JsonPropertyName(\"created\")]\n public long? Created { get; init; }\n\n [JsonPropertyName(\"choices\")]\n public List<ChatCompletionChoice>? Choices { get; init; }\n\n [JsonPropertyName(\"usage\")]\n public UsageDto? Usage { get; init; }\n }\n\n private sealed class ChatCompletionChunk\n {\n [JsonPropertyName(\"id\")]\n public string? Id { get; init; }\n\n [JsonPropertyName(\"model\")]\n public string? Model { get; init; }\n\n [JsonPropertyName(\"created\")]\n public long? Created { get; init; }\n\n [JsonPropertyName(\"choices\")]\n public List<ChatCompletionChunkChoice>? Choices { get; init; }\n\n [JsonPropertyName(\"usage\")]\n public UsageDto? Usage { get; init; }\n }\n\n private sealed class ChatCompletionChoice\n {\n [JsonPropertyName(\"message\")]\n public ChatMessageDto? Message { get; init; }\n\n [JsonPropertyName(\"finish_reason\")]\n public string? FinishReason { get; init; }\n }\n\n private sealed class ChatCompletionChunkChoice\n {\n [JsonPropertyName(\"delta\")]\n public ChatDeltaDto? Delta { get; init; }\n\n [JsonPropertyName(\"finish_reason\")]\n public string? FinishReason { get; init; }\n }\n\n private sealed class ChatMessageDto\n {\n [JsonPropertyName(\"content\")]\n public string? Content { get; init; }\n\n [JsonPropertyName(\"reasoning_content\")]\n public string? ReasoningContent { get; init; }\n\n [JsonPropertyName(\"tool_calls\")]\n public List<ToolCallDto>? ToolCalls { get; init; }\n }\n\n private sealed class ChatDeltaDto\n {\n [JsonPropertyName(\"content\")]\n public string? Content { get; init; }\n\n [JsonPropertyName(\"reasoning_content\")]\n public string? ReasoningContent { get; init; }\n\n [JsonPropertyName(\"tool_calls\")]\n public List<ToolCallDeltaDto>? ToolCalls { get; init; }\n }\n\n private sealed class ToolCallDto\n {\n [JsonPropertyName(\"id\")]\n public string? Id { get; init; }\n\n [JsonPropertyName(\"function\")]\n public ToolFunctionDto? Function { get; init; }\n }\n\n private sealed class ToolCallDeltaDto\n {\n [JsonPropertyName(\"index\")]\n public int? Index { get; init; }\n\n [JsonPropertyName(\"id\")]\n public string? Id { get; init; }\n\n [JsonPropertyName(\"function\")]\n public ToolFunctionDto? Function { get; init; }\n }\n\n private sealed class ToolFunctionDto\n {\n [JsonPropertyName(\"name\")]\n public string? Name { get; init; }\n\n [JsonPropertyName(\"arguments\")]\n public string? Arguments { get; init; }\n }\n\n private sealed class UsageDto\n {\n [JsonPropertyName(\"prompt_tokens\")]\n public int? PromptTokens { get; init; }\n\n [JsonPropertyName(\"completion_tokens\")]\n public int? CompletionTokens { get; init; }\n\n [JsonPropertyName(\"total_tokens\")]\n public int? TotalTokens { get; init; }\n\n [JsonPropertyName(\"prompt_tokens_details\")]\n public PromptTokensDetailsDto? PromptTokensDetails { get; init; }\n\n [JsonPropertyName(\"completion_tokens_details\")]\n public CompletionTokensDetailsDto? CompletionTokensDetails { get; init; }\n }\n\n private sealed class PromptTokensDetailsDto\n {\n [JsonPropertyName(\"cached_tokens\")]\n public int? CachedTokens { get; init; }\n }\n\n private sealed class CompletionTokensDetailsDto\n {\n [JsonPropertyName(\"reasoning_tokens\")]\n public int? ReasoningTokens { get; init; }\n }\n}\n"}, "1f5050c03db13081755e60b2ea722654fb95b84c": {"t": 1785760374.7320285, "n": 36498, "h": "b9fc78da85ab3fbf", "c": "using System.Net.Http.Headers;\nusing System.Runtime.CompilerServices;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.Json.Nodes;\nusing System.Text.Json.Serialization;\nusing Microsoft.Extensions.AI;\n\nnamespace OpenDeepWiki.Agents;\n\npublic sealed class DeepSeekOpenAIChatClient : IChatClient\n{\n private const string DefaultEndpoint = \"https://api.deepseek.com/v1\";\n private const string DefaultPromptCacheKey = \"opendeepwiki\";\n\n private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)\n {\n DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull\n };\n\n private readonly string _model;\n private readonly string _apiKey;\n private readonly HttpClient _httpClient;\n private readonly AiRequestOptions _options;\n private readonly bool _disposeHttpClient;\n\n public DeepSeekOpenAIChatClient(\n string model,\n string? endpoint,\n string apiKey,\n HttpClient httpClient,\n AiRequestOptions options,\n bool disposeHttpClient = false)\n {\n _model = string.IsNullOrWhiteSpace(model)\n ? throw new ArgumentException(\"Model is required.\", nameof(model))\n : model;\n _apiKey = string.IsNullOrWhiteSpace(apiKey)\n ? throw new ArgumentException(\"API key is required.\", nameof(apiKey))\n : apiKey;\n _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));\n _options = options ?? throw new ArgumentNullException(nameof(options));\n _options.Endpoint = NormalizeEndpoint(endpoint);\n _disposeHttpClient = disposeHttpClient;\n }\n\n public async Task<ChatResponse> GetResponseAsync(\n IEnumerable<ChatMessage> messages,\n ChatOptions? options = null,\n CancellationToken cancellationToken = default)\n {\n using var request = CreateRequest(messages, options, stream: false);\n using var response = await _httpClient.SendAsync(request, cancellationToken);\n var content = await response.Content.ReadAsStringAsync(cancellationToken);\n EnsureSuccessStatusCode(response, content);\n\n var completion = JsonSerializer.Deserialize<ChatCompletionResponse>(content, JsonOptions)\n ?? throw new JsonException(\"DeepSeek returned an empty chat completion response.\");\n var choice = completion.Choices?.FirstOrDefault();\n var message = choice?.Message ?? new ChatMessageDto();\n var chatMessage = CreateAssistantMessage(message);\n\n return new ChatResponse(chatMessage)\n {\n ResponseId = completion.Id,\n ModelId = completion.Model ?? _model,\n CreatedAt = ConvertCreatedAt(completion.Created),\n FinishReason = MapFinishReason(choice?.FinishReason),\n Usage = MapUsage(completion.Usage),\n RawRepresentation = content\n };\n }\n\n public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(\n IEnumerable<ChatMessage> messages,\n ChatOptions? options = null,\n [EnumeratorCancellation] CancellationToken cancellationToken = default)\n {\n using var request = CreateRequest(messages, options, stream: true);\n using var response = await _httpClient.SendAsync(\n request,\n HttpCompletionOption.ResponseHeadersRead,\n cancellationToken);\n var errorContent = response.IsSuccessStatusCode\n ? null\n : await response.Content.ReadAsStringAsync(cancellationToken);\n EnsureSuccessStatusCode(response, errorContent);\n\n await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);\n var toolCalls = new Dictionary<int, StreamingToolCallBuilder>();\n var emittedToolCalls = false;\n string? responseId = null;\n string? modelId = null;\n DateTimeOffset? createdAt = null;\n var messageId = Guid.NewGuid().ToString(\"N\");\n\n await foreach (var payload in ReadSseDataAsync(stream, cancellationToken))\n {\n if (IsDonePayload(payload))\n {\n break;\n }\n\n ChatCompletionChunk chunk;\n try\n {\n chunk = JsonSerializer.Deserialize<ChatCompletionChunk>(payload, JsonOptions)\n ?? throw new JsonException(\"DeepSeek returned an empty stream chunk.\");\n }\n catch (JsonException ex)\n {\n throw new JsonException($\"Invalid DeepSeek stream data: {TrimForError(payload)}\", ex);\n }\n\n responseId ??= chunk.Id;\n modelId = chunk.Model ?? modelId ?? _model;\n createdAt ??= ConvertCreatedAt(chunk.Created);\n\n if (chunk.Usage != null)\n {\n yield return new ChatResponseUpdate\n {\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n Contents = [new UsageContent(MapUsage(chunk.Usage)!)],\n RawRepresentation = payload\n };\n }\n\n if (chunk.Choices is not { Count: > 0 })\n {\n continue;\n }\n\n foreach (var choice in chunk.Choices)\n {\n var delta = choice.Delta;\n if (!string.IsNullOrEmpty(delta?.Content))\n {\n yield return new ChatResponseUpdate(ChatRole.Assistant, delta.Content)\n {\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n RawRepresentation = payload\n };\n }\n\n if (!string.IsNullOrEmpty(delta?.ReasoningContent))\n {\n var reasoningContent = new TextReasoningContent(delta.ReasoningContent)\n {\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"reasoning_content\"] = delta.ReasoningContent\n }\n };\n var reasoningUpdate = new ChatResponseUpdate(ChatRole.Assistant, [reasoningContent])\n {\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n RawRepresentation = payload,\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"reasoning_content\"] = delta.ReasoningContent\n }\n };\n yield return reasoningUpdate;\n }\n\n AccumulateToolCalls(toolCalls, delta?.ToolCalls);\n\n var finishReason = MapFinishReason(choice.FinishReason);\n if (finishReason == ChatFinishReason.ToolCalls && toolCalls.Count > 0)\n {\n emittedToolCalls = true;\n yield return CreateToolCallsUpdate(\n toolCalls,\n responseId,\n messageId,\n modelId,\n createdAt,\n finishReason,\n payload);\n }\n else if (finishReason != null)\n {\n yield return new ChatResponseUpdate\n {\n Role = ChatRole.Assistant,\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n FinishReason = finishReason,\n RawRepresentation = payload\n };\n }\n }\n }\n\n if (!emittedToolCalls && toolCalls.Count > 0)\n {\n yield return CreateToolCallsUpdate(\n toolCalls,\n responseId,\n messageId,\n modelId ?? _model,\n createdAt,\n ChatFinishReason.ToolCalls,\n null);\n }\n }\n\n public object? GetService(Type serviceType, object? serviceKey = null)\n {\n return serviceKey == null && serviceType.IsInstanceOfType(this) ? this : null;\n }\n\n public void Dispose()\n {\n if (_disposeHttpClient)\n {\n _httpClient.Dispose();\n }\n }\n\n private HttpRequestMessage CreateRequest(\n IEnumerable<ChatMessage> messages,\n ChatOptions? options,\n bool stream)\n {\n var requestMessages = BuildMessages(messages);\n PrependInstructions(requestMessages, options?.Instructions);\n\n var body = new JsonObject\n {\n [\"model\"] = options?.ModelId ?? _model,\n [\"stream\"] = stream,\n [\"prompt_cache_key\"] = ResolvePromptCacheKey(options),\n [\"messages\"] = requestMessages\n };\n\n if (stream)\n {\n body[\"stream_options\"] = new JsonObject\n {\n [\"include_usage\"] = true\n };\n }\n\n ApplyCommonOptions(body, options);\n ApplyTools(body, options);\n ApplyRequestOverrides(body, null, _options.ProviderRequestOverridesJson);\n ApplyRequestOverrides(body, null, _options.ModelRequestOverridesJson);\n ApplyThinkingConfig(body, options);\n\n var request = new HttpRequestMessage(\n HttpMethod.Post,\n AppendEndpointPath(_options.Endpoint ?? DefaultEndpoint, \"chat/completions\"))\n {\n Content = new StringContent(body.ToJsonString(JsonOptions), Encoding.UTF8, \"application/json\")\n };\n\n request.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", _apiKey);\n request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(stream ? \"text/event-stream\" : \"application/json\"));\n ApplyRequestOverrides(body, request, _options.ProviderRequestOverridesJson);\n ApplyRequestOverrides(body, request, _options.ModelRequestOverridesJson);\n return request;\n }\n\n private static JsonArray BuildMessages(IEnumerable<ChatMessage> messages)\n {\n var array = new JsonArray();\n foreach (var message in messages)\n {\n var functionResults = message.Contents.OfType<FunctionResultContent>().ToList();\n if (functionResults.Count > 0)\n {\n foreach (var result in functionResults)\n {\n array.Add(new JsonObject\n {\n [\"role\"] = \"tool\",\n [\"tool_call_id\"] = result.CallId,\n [\"content\"] = SerializeToolResult(result.Result)\n });\n }\n\n continue;\n }\n\n var functionCalls = message.Contents.OfType<FunctionCallContent>().ToList();\n var role = ToOpenAIRole(message.Role);\n var node = new JsonObject\n {\n [\"role\"] = role\n };\n\n var text = GetText(message.Contents);\n node[\"content\"] = string.IsNullOrEmpty(text) && functionCalls.Count > 0 ? null : text;\n\n var reasoningContent = GetReasoningContent(message);\n if (role == \"assistant\" && !string.IsNullOrEmpty(reasoningContent))\n {\n node[\"reasoning_content\"] = reasoningContent;\n }\n\n if (functionCalls.Count > 0)\n {\n var toolCalls = new JsonArray();\n foreach (var call in functionCalls)\n {\n toolCalls.Add(new JsonObject\n {\n [\"id\"] = call.CallId,\n [\"type\"] = \"function\",\n [\"function\"] = new JsonObject\n {\n [\"name\"] = call.Name,\n [\"arguments\"] = JsonSerializer.Serialize(call.Arguments, JsonOptions)\n }\n });\n }\n\n node[\"tool_calls\"] = toolCalls;\n }\n\n array.Add(node);\n }\n\n return array;\n }\n\n private static void PrependInstructions(JsonArray messages, string? instructions)\n {\n if (string.IsNullOrWhiteSpace(instructions) || HasSystemMessage(messages))\n {\n return;\n }\n\n messages.Insert(0, new JsonObject\n {\n [\"role\"] = \"system\",\n [\"content\"] = instructions\n });\n }\n\n private static bool HasSystemMessage(JsonArray messages)\n {\n foreach (var message in messages)\n {\n if (message is JsonObject node &&\n node[\"role\"]?.GetValue<string>().Equals(\"system\", StringComparison.OrdinalIgnoreCase) == true)\n {\n return true;\n }\n }\n\n return false;\n }\n\n private static void ApplyCommonOptions(JsonObject body, ChatOptions? options)\n {\n if (options == null)\n {\n return;\n }\n\n if (options.MaxOutputTokens.HasValue)\n {\n body[\"max_tokens\"] = options.MaxOutputTokens.Value;\n }\n\n if (options.Temperature.HasValue)\n {\n body[\"temperature\"] = options.Temperature.Value;\n }\n\n if (options.TopP.HasValue)\n {\n body[\"top_p\"] = options.TopP.Value;\n }\n\n if (options.FrequencyPenalty.HasValue)\n {\n body[\"frequency_penalty\"] = options.FrequencyPenalty.Value;\n }\n\n if (options.PresencePenalty.HasValue)\n {\n body[\"presence_penalty\"] = options.PresencePenalty.Value;\n }\n\n if (options.Seed.HasValue)\n {\n body[\"seed\"] = options.Seed.Value;\n }\n\n if (options.StopSequences is { Count: > 0 })\n {\n var stops = new JsonArray();\n foreach (var stop in options.StopSequences)\n {\n stops.Add(stop);\n }\n\n body[\"stop\"] = stops;\n }\n }\n\n private static void ApplyTools(JsonObject body, ChatOptions? options)\n {\n if (options?.Tools is not { Count: > 0 })\n {\n return;\n }\n\n var tools = new JsonArray();\n foreach (var tool in options.Tools)\n {\n if (tool is not AIFunctionDeclaration function)\n {\n continue;\n }\n\n tools.Add(new JsonObject\n {\n [\"type\"] = \"function\",\n [\"function\"] = new JsonObject\n {\n [\"name\"] = function.Name,\n [\"description\"] = function.Description ?? string.Empty,\n [\"parameters\"] = CloneJsonElement(function.JsonSchema) ?? new JsonObject\n {\n [\"type\"] = \"object\"\n }\n }\n });\n }\n\n if (tools.Count == 0)\n {\n return;\n }\n\n body[\"tools\"] = tools;\n body[\"tool_choice\"] = options.ToolMode switch\n {\n NoneChatToolMode => JsonValue.Create(\"none\"),\n RequiredChatToolMode required when !string.IsNullOrWhiteSpace(required.RequiredFunctionName) => new JsonObject\n {\n [\"type\"] = \"function\",\n [\"function\"] = new JsonObject\n {\n [\"name\"] = required.RequiredFunctionName\n }\n },\n RequiredChatToolMode => JsonValue.Create(\"required\"),\n _ => JsonValue.Create(\"auto\")\n };\n }\n\n private void ApplyThinkingConfig(JsonObject body, ChatOptions? options)\n {\n if (!_options.SupportsThinking || string.IsNullOrWhiteSpace(_options.ThinkingConfigJson))\n {\n return;\n }\n\n var enabled = ResolveThinkingEnabled(options);\n if (enabled == null)\n {\n return;\n }\n\n JsonNode? parsed;\n try\n {\n parsed = JsonNode.Parse(_options.ThinkingConfigJson);\n }\n catch (JsonException)\n {\n return;\n }\n\n if (parsed is not JsonObject config)\n {\n return;\n }\n\n var bodyParamName = enabled.Value ? \"bodyParams\" : \"disabledBodyParams\";\n if (config[bodyParamName] is JsonObject bodyParams)\n {\n MergeJsonObject(body, bodyParams);\n }\n\n if (enabled.Value && config[\"forceTemperature\"] is JsonValue forceTemperature)\n {\n body[\"temperature\"] = forceTemperature.DeepClone();\n }\n }\n\n private bool? ResolveThinkingEnabled(ChatOptions? options)\n {\n if (TryReadBoolean(options?.AdditionalProperties, \"thinkingEnabled\", out var thinkingEnabled) ||\n TryReadBoolean(options?.AdditionalProperties, \"enableThinking\", out thinkingEnabled) ||\n TryReadBoolean(options?.AdditionalProperties, \"enable_thinking\", out thinkingEnabled))\n {\n return thinkingEnabled;\n }\n\n return _options.ThinkingEnabled ?? true;\n }\n\n private string ResolvePromptCacheKey(ChatOptions? options)\n {\n if (TryReadString(options?.AdditionalProperties, \"promptCacheKey\", out var promptCacheKey) ||\n TryReadString(options?.AdditionalProperties, \"prompt_cache_key\", out promptCacheKey))\n {\n return NormalizePromptCacheKey(promptCacheKey);\n }\n\n return NormalizePromptCacheKey(_options.PromptCacheKey);\n }\n\n private static string NormalizePromptCacheKey(string? promptCacheKey)\n {\n return string.IsNullOrWhiteSpace(promptCacheKey)\n ? DefaultPromptCacheKey\n : promptCacheKey.Trim();\n }\n\n private static void ApplyRequestOverrides(\n JsonObject body,\n HttpRequestMessage? request,\n string? overridesJson)\n {\n if (string.IsNullOrWhiteSpace(overridesJson))\n {\n return;\n }\n\n JsonNode? parsed;\n try\n {\n parsed = JsonNode.Parse(overridesJson);\n }\n catch (JsonException)\n {\n return;\n }\n\n if (parsed is not JsonObject overrides)\n {\n return;\n }\n\n if (request == null)\n {\n if (overrides[\"bodyParams\"] is JsonObject bodyParams)\n {\n MergeJsonObject(body, bodyParams);\n }\n\n if (overrides[\"body\"] is JsonObject bodyOverride)\n {\n MergeJsonObject(body, bodyOverride);\n }\n\n foreach (var pair in overrides)\n {\n if (pair.Key.Equals(\"headers\", StringComparison.OrdinalIgnoreCase) ||\n pair.Key.Equals(\"bodyParams\", StringComparison.OrdinalIgnoreCase) ||\n pair.Key.Equals(\"body\", StringComparison.OrdinalIgnoreCase))\n {\n continue;\n }\n\n body[pair.Key] = pair.Value?.DeepClone();\n }\n\n return;\n }\n\n if (overrides[\"headers\"] is not JsonObject headers)\n {\n return;\n }\n\n foreach (var header in headers)\n {\n if (header.Value == null)\n {\n continue;\n }\n\n var value = header.Value.GetValueKind() == JsonValueKind.String\n ? header.Value.GetValue<string>()\n : header.Value.ToJsonString(JsonOptions);\n if (string.IsNullOrWhiteSpace(value))\n {\n continue;\n }\n\n if (header.Key.Equals(\"Content-Type\", StringComparison.OrdinalIgnoreCase))\n {\n request.Content?.Headers.Remove(header.Key);\n request.Content?.Headers.TryAddWithoutValidation(header.Key, value);\n }\n else\n {\n request.Headers.Remove(header.Key);\n request.Headers.TryAddWithoutValidation(header.Key, value);\n }\n }\n }\n\n private static ChatMessage CreateAssistantMessage(ChatMessageDto message)\n {\n var contents = new List<AIContent>();\n if (!string.IsNullOrEmpty(message.ReasoningContent))\n {\n contents.Add(new TextReasoningContent(message.ReasoningContent)\n {\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"reasoning_content\"] = message.ReasoningContent\n }\n });\n }\n\n if (!string.IsNullOrEmpty(message.Content))\n {\n contents.Add(new TextContent(message.Content));\n }\n\n if (message.ToolCalls is { Count: > 0 })\n {\n foreach (var toolCall in message.ToolCalls)\n {\n var function = toolCall.Function;\n if (string.IsNullOrWhiteSpace(toolCall.Id) || string.IsNullOrWhiteSpace(function?.Name))\n {\n continue;\n }\n\n contents.Add(new FunctionCallContent(\n toolCall.Id,\n function.Name,\n ParseArguments(function.Arguments)));\n }\n }\n\n return new ChatMessage(ChatRole.Assistant, contents);\n }\n\n private static ChatResponseUpdate CreateToolCallsUpdate(\n Dictionary<int, StreamingToolCallBuilder> toolCalls,\n string? responseId,\n string messageId,\n string? modelId,\n DateTimeOffset? createdAt,\n ChatFinishReason? finishReason,\n string? rawPayload)\n {\n var contents = toolCalls\n .OrderBy(pair => pair.Key)\n .Select(pair => pair.Value)\n .Where(call => !string.IsNullOrWhiteSpace(call.Id) && !string.IsNullOrWhiteSpace(call.Name))\n .Select(call => (AIContent)new FunctionCallContent(\n call.Id!,\n call.Name!,\n ParseArguments(call.Arguments.ToString())))\n .ToList();\n\n return new ChatResponseUpdate(ChatRole.Assistant, contents)\n {\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n FinishReason = finishReason,\n RawRepresentation = rawPayload\n };\n }\n\n private static void AccumulateToolCalls(\n Dictionary<int, StreamingToolCallBuilder> toolCalls,\n IReadOnlyList<ToolCallDeltaDto>? deltas)\n {\n if (deltas is not { Count: > 0 })\n {\n return;\n }\n\n for (var position = 0; position < deltas.Count; position++)\n {\n var delta = deltas[position];\n var index = delta.Index ?? position;\n if (!toolCalls.TryGetValue(index, out var builder))\n {\n builder = new StreamingToolCallBuilder();\n toolCalls[index] = builder;\n }\n\n if (!string.IsNullOrWhiteSpace(delta.Id))\n {\n builder.Id = delta.Id;\n }\n\n if (!string.IsNullOrWhiteSpace(delta.Function?.Name))\n {\n builder.Name = delta.Function.Name;\n }\n\n if (!string.IsNullOrEmpty(delta.Function?.Arguments))\n {\n builder.Arguments.Append(delta.Function.Arguments);\n }\n }\n }\n\n private static async IAsyncEnumerable<string> ReadSseDataAsync(\n Stream stream,\n [EnumeratorCancellation] CancellationToken cancellationToken)\n {\n using var reader = new StreamReader(stream, Encoding.UTF8);\n var data = new StringBuilder();\n while (await reader.ReadLineAsync(cancellationToken) is { } line)\n {\n if (line.Length == 0)\n {\n if (data.Length > 0)\n {\n yield return data.ToString();\n data.Clear();\n }\n\n continue;\n }\n\n if (line.StartsWith(':'))\n {\n continue;\n }\n\n if (!line.StartsWith(\"data:\", StringComparison.OrdinalIgnoreCase))\n {\n continue;\n }\n\n if (data.Length > 0)\n {\n data.Append('\\n');\n }\n\n data.Append(line[5..].TrimStart());\n }\n\n if (data.Length > 0)\n {\n yield return data.ToString();\n }\n }\n\n private static UsageDetails? MapUsage(UsageDto? usage)\n {\n if (usage == null)\n {\n return null;\n }\n\n return new UsageDetails\n {\n InputTokenCount = usage.PromptTokens,\n OutputTokenCount = usage.CompletionTokens,\n TotalTokenCount = usage.TotalTokens,\n CachedInputTokenCount = usage.PromptTokensDetails?.CachedTokens,\n ReasoningTokenCount = usage.CompletionTokensDetails?.ReasoningTokens\n };\n }\n\n private static ChatFinishReason? MapFinishReason(string? finishReason)\n {\n return finishReason?.Trim().ToLowerInvariant() switch\n {\n \"stop\" => ChatFinishReason.Stop,\n \"length\" => ChatFinishReason.Length,\n \"tool_calls\" or \"function_call\" => ChatFinishReason.ToolCalls,\n \"content_filter\" => ChatFinishReason.ContentFilter,\n _ => null\n };\n }\n\n private static IDictionary<string, object?> ParseArguments(string? arguments)\n {\n if (string.IsNullOrWhiteSpace(arguments))\n {\n return new Dictionary<string, object?>();\n }\n\n try\n {\n using var document = JsonDocument.Parse(arguments);\n if (document.RootElement.ValueKind != JsonValueKind.Object)\n {\n return new Dictionary<string, object?>\n {\n [\"value\"] = ConvertJsonElement(document.RootElement)\n };\n }\n\n return document.RootElement.EnumerateObject()\n .ToDictionary(\n property => property.Name,\n property => ConvertJsonElement(property.Value));\n }\n catch (JsonException)\n {\n return new Dictionary<string, object?>\n {\n [\"value\"] = arguments\n };\n }\n }\n\n private static object? ConvertJsonElement(JsonElement element)\n {\n return element.ValueKind switch\n {\n JsonValueKind.Object => element.EnumerateObject()\n .ToDictionary(property => property.Name, property => ConvertJsonElement(property.Value)),\n JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonElement).ToList(),\n JsonValueKind.String => element.GetString(),\n JsonValueKind.Number when element.TryGetInt64(out var longValue) => longValue,\n JsonValueKind.Number when element.TryGetDouble(out var doubleValue) => doubleValue,\n JsonValueKind.True => true,\n JsonValueKind.False => false,\n JsonValueKind.Null => null,\n _ => element.GetRawText()\n };\n }\n\n private static string SerializeToolResult(object? result)\n {\n return result switch\n {\n null => \"null\",\n string text => text,\n JsonElement element => element.GetRawText(),\n JsonNode node => node.ToJsonString(JsonOptions),\n _ => JsonSerializer.Serialize(result, JsonOptions)\n };\n }\n\n private static string GetText(IEnumerable<AIContent> contents)\n {\n return string.Concat(contents.OfType<TextContent>().Select(content => content.Text));\n }\n\n private static string GetReasoningContent(ChatMessage message)\n {\n var reasoning = string.Concat(message.Contents\n .OfType<TextReasoningContent>()\n .Select(content => content.Text));\n if (!string.IsNullOrEmpty(reasoning))\n {\n return reasoning;\n }\n\n if (TryReadString(message.AdditionalProperties, \"reasoning_content\", out var rawReasoning))\n {\n return rawReasoning;\n }\n\n return string.Empty;\n }\n\n private static JsonNode? CloneJsonElement(JsonElement element)\n {\n return element.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null\n ? null\n : JsonNode.Parse(element.GetRawText());\n }\n\n private static void MergeJsonObject(JsonObject target, JsonObject source)\n {\n foreach (var pair in source)\n {\n target[pair.Key] = pair.Value?.DeepClone();\n }\n }\n\n private static bool TryReadBoolean(\n AdditionalPropertiesDictionary? properties,\n string key,\n out bool value)\n {\n value = false;\n if (properties == null || !properties.TryGetValue(key, out var raw))\n {\n return false;\n }\n\n switch (raw)\n {\n case bool boolValue:\n value = boolValue;\n return true;\n case string text when bool.TryParse(text, out var parsed):\n value = parsed;\n return true;\n default:\n return false;\n }\n }\n\n private static bool TryReadString(\n AdditionalPropertiesDictionary? properties,\n string key,\n out string value)\n {\n value = string.Empty;\n if (properties == null || !properties.TryGetValue(key, out var raw))\n {\n return false;\n }\n\n switch (raw)\n {\n case string text:\n value = text;\n return true;\n case JsonElement element when element.ValueKind == JsonValueKind.String:\n value = element.GetString() ?? string.Empty;\n return true;\n case JsonNode node when node.GetValueKind() == JsonValueKind.String:\n value = node.GetValue<string>();\n return true;\n default:\n return false;\n }\n }\n\n private static DateTimeOffset? ConvertCreatedAt(long? unixSeconds)\n {\n return unixSeconds.HasValue ? DateTimeOffset.FromUnixTimeSeconds(unixSeconds.Value) : null;\n }\n\n private static string ToOpenAIRole(ChatRole role)\n {\n if (role == ChatRole.System)\n {\n return \"system\";\n }\n\n if (role == ChatRole.Assistant)\n {\n return \"assistant\";\n }\n\n if (role == ChatRole.Tool)\n {\n return \"tool\";\n }\n\n return \"user\";\n }\n\n private static string NormalizeEndpoint(string? endpoint)\n {\n return string.IsNullOrWhiteSpace(endpoint)\n ? DefaultEndpoint\n : endpoint.TrimEnd('/');\n }\n\n private static string AppendEndpointPath(string baseUrl, string path)\n {\n var trimmed = baseUrl.TrimEnd('/');\n if (trimmed.EndsWith($\"/{path}\", StringComparison.OrdinalIgnoreCase))\n {\n return trimmed;\n }\n\n return $\"{trimmed}/{path}\";\n }\n\n private static bool IsDonePayload(string payload)\n {\n return payload.Equals(\"[DONE]\", StringComparison.OrdinalIgnoreCase) ||\n payload.Equals(\"DONE\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void EnsureSuccessStatusCode(HttpResponseMessage response, string? content)\n {\n if (response.IsSuccessStatusCode)\n {\n return;\n }\n\n var message = string.IsNullOrWhiteSpace(content)\n ? response.ReasonPhrase\n : TrimForError(content);\n throw new HttpRequestException(\n $\"DeepSeek OpenAI-compatible request failed with HTTP {(int)response.StatusCode}: {message}\",\n null,\n response.StatusCode);\n }\n\n private static string TrimForError(string text)\n {\n var trimmed = text.Trim();\n return trimmed.Length <= 600 ? trimmed : trimmed[..600];\n }\n\n private sealed class StreamingToolCallBuilder\n {\n public string? Id { get; set; }\n public string? Name { get; set; }\n public StringBuilder Arguments { get; } = new();\n }\n\n private sealed class ChatCompletionResponse\n {\n [JsonPropertyName(\"id\")]\n public string? Id { get; init; }\n\n [JsonPropertyName(\"model\")]\n public string? Model { get; init; }\n\n [JsonPropertyName(\"created\")]\n public long? Created { get; init; }\n\n [JsonPropertyName(\"choices\")]\n public List<ChatCompletionChoice>? Choices { get; init; }\n\n [JsonPropertyName(\"usage\")]\n public UsageDto? Usage { get; init; }\n }\n\n private sealed class ChatCompletionChunk\n {\n [JsonPropertyName(\"id\")]\n public string? Id { get; init; }\n\n [JsonPropertyName(\"model\")]\n public string? Model { get; init; }\n\n [JsonPropertyName(\"created\")]\n public long? Created { get; init; }\n\n [JsonPropertyName(\"choices\")]\n public List<ChatCompletionChunkChoice>? Choices { get; init; }\n\n [JsonPropertyName(\"usage\")]\n public UsageDto? Usage { get; init; }\n }\n\n private sealed class ChatCompletionChoice\n {\n [JsonPropertyName(\"message\")]\n public ChatMessageDto? Message { get; init; }\n\n [JsonPropertyName(\"finish_reason\")]\n public string? FinishReason { get; init; }\n }\n\n private sealed class ChatCompletionChunkChoice\n {\n [JsonPropertyName(\"delta\")]\n public ChatDeltaDto? Delta { get; init; }\n\n [JsonPropertyName(\"finish_reason\")]\n public string? FinishReason { get; init; }\n }\n\n private sealed class ChatMessageDto\n {\n [JsonPropertyName(\"content\")]\n public string? Content { get; init; }\n\n [JsonPropertyName(\"reasoning_content\")]\n public string? ReasoningContent { get; init; }\n\n [JsonPropertyName(\"tool_calls\")]\n public List<ToolCallDto>? ToolCalls { get; init; }\n }\n\n private sealed class ChatDeltaDto\n {\n [JsonPropertyName(\"content\")]\n public string? Content { get; init; }\n\n [JsonPropertyName(\"reasoning_content\")]\n public string? ReasoningContent { get; init; }\n\n [JsonPropertyName(\"tool_calls\")]\n public List<ToolCallDeltaDto>? ToolCalls { get; init; }\n }\n\n private sealed class ToolCallDto\n {\n [JsonPropertyName(\"id\")]\n public string? Id { get; init; }\n\n [JsonPropertyName(\"function\")]\n public ToolFunctionDto? Function { get; init; }\n }\n\n private sealed class ToolCallDeltaDto\n {\n [JsonPropertyName(\"index\")]\n public int? Index { get; init; }\n\n [JsonPropertyName(\"id\")]\n public string? Id { get; init; }\n\n [JsonPropertyName(\"function\")]\n public ToolFunctionDto? Function { get; init; }\n }\n\n private sealed class ToolFunctionDto\n {\n [JsonPropertyName(\"name\")]\n public string? Name { get; init; }\n\n [JsonPropertyName(\"arguments\")]\n public string? Arguments { get; init; }\n }\n\n private sealed class UsageDto\n {\n [JsonPropertyName(\"prompt_tokens\")]\n public int? PromptTokens { get; init; }\n\n [JsonPropertyName(\"completion_tokens\")]\n public int? CompletionTokens { get; init; }\n\n [JsonPropertyName(\"total_tokens\")]\n public int? TotalTokens { get; init; }\n\n [JsonPropertyName(\"prompt_tokens_details\")]\n public PromptTokensDetailsDto? PromptTokensDetails { get; init; }\n\n [JsonPropertyName(\"completion_tokens_details\")]\n public CompletionTokensDetailsDto? CompletionTokensDetails { get; init; }\n }\n\n private sealed class PromptTokensDetailsDto\n {\n [JsonPropertyName(\"cached_tokens\")]\n public int? CachedTokens { get; init; }\n }\n\n private sealed class CompletionTokensDetailsDto\n {\n [JsonPropertyName(\"reasoning_tokens\")]\n public int? ReasoningTokens { get; init; }\n }\n}\n"}, "24e31a340b661bee848fad546832d68b7bcfa3d3": {"t": 1785760376.441388, "n": 35867, "h": "7f6bc1bca60b6f36", "c": "using System.Net.Http.Headers;\nusing System.Runtime.CompilerServices;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.Json.Nodes;\nusing System.Text.Json.Serialization;\nusing Microsoft.Extensions.AI;\n\nnamespace OpenDeepWiki.Agents;\n\npublic sealed class DeepSeekOpenAIChatClient : IChatClient\n{\n private const string DefaultEndpoint = \"https://api.deepseek.com/v1\";\n\n private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)\n {\n DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull\n };\n\n private static readonly string PromptCacheKey = $\"opendeepwiki-{Guid.NewGuid():N}\";\n\n private readonly string _model;\n private readonly string _apiKey;\n private readonly HttpClient _httpClient;\n private readonly AiRequestOptions _options;\n private readonly bool _disposeHttpClient;\n\n public DeepSeekOpenAIChatClient(\n string model,\n string? endpoint,\n string apiKey,\n HttpClient httpClient,\n AiRequestOptions options,\n bool disposeHttpClient = false)\n {\n _model = string.IsNullOrWhiteSpace(model)\n ? throw new ArgumentException(\"Model is required.\", nameof(model))\n : model;\n _apiKey = string.IsNullOrWhiteSpace(apiKey)\n ? throw new ArgumentException(\"API key is required.\", nameof(apiKey))\n : apiKey;\n _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));\n _options = options ?? throw new ArgumentNullException(nameof(options));\n _options.Endpoint = NormalizeEndpoint(endpoint);\n _disposeHttpClient = disposeHttpClient;\n }\n\n public async Task<ChatResponse> GetResponseAsync(\n IEnumerable<ChatMessage> messages,\n ChatOptions? options = null,\n CancellationToken cancellationToken = default)\n {\n using var request = CreateRequest(messages, options, stream: false);\n using var response = await _httpClient.SendAsync(request, cancellationToken);\n var content = await response.Content.ReadAsStringAsync(cancellationToken);\n EnsureSuccessStatusCode(response, content);\n\n var completion = JsonSerializer.Deserialize<ChatCompletionResponse>(content, JsonOptions)\n ?? throw new JsonException(\"DeepSeek returned an empty chat completion response.\");\n var choice = completion.Choices?.FirstOrDefault();\n var message = choice?.Message ?? new ChatMessageDto();\n var chatMessage = CreateAssistantMessage(message);\n\n return new ChatResponse(chatMessage)\n {\n ResponseId = completion.Id,\n ModelId = completion.Model ?? _model,\n CreatedAt = ConvertCreatedAt(completion.Created),\n FinishReason = MapFinishReason(choice?.FinishReason),\n Usage = MapUsage(completion.Usage),\n RawRepresentation = content\n };\n }\n\n public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(\n IEnumerable<ChatMessage> messages,\n ChatOptions? options = null,\n [EnumeratorCancellation] CancellationToken cancellationToken = default)\n {\n using var request = CreateRequest(messages, options, stream: true);\n using var response = await _httpClient.SendAsync(\n request,\n HttpCompletionOption.ResponseHeadersRead,\n cancellationToken);\n var errorContent = response.IsSuccessStatusCode\n ? null\n : await response.Content.ReadAsStringAsync(cancellationToken);\n EnsureSuccessStatusCode(response, errorContent);\n\n await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);\n var toolCalls = new Dictionary<int, StreamingToolCallBuilder>();\n var emittedToolCalls = false;\n string? responseId = null;\n string? modelId = null;\n DateTimeOffset? createdAt = null;\n var messageId = Guid.NewGuid().ToString(\"N\");\n\n await foreach (var payload in ReadSseDataAsync(stream, cancellationToken))\n {\n if (IsDonePayload(payload))\n {\n break;\n }\n\n ChatCompletionChunk chunk;\n try\n {\n chunk = JsonSerializer.Deserialize<ChatCompletionChunk>(payload, JsonOptions)\n ?? throw new JsonException(\"DeepSeek returned an empty stream chunk.\");\n }\n catch (JsonException ex)\n {\n throw new JsonException($\"Invalid DeepSeek stream data: {TrimForError(payload)}\", ex);\n }\n\n responseId ??= chunk.Id;\n modelId = chunk.Model ?? modelId ?? _model;\n createdAt ??= ConvertCreatedAt(chunk.Created);\n\n if (chunk.Usage != null)\n {\n yield return new ChatResponseUpdate\n {\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n Contents = [new UsageContent(MapUsage(chunk.Usage)!)],\n RawRepresentation = payload\n };\n }\n\n if (chunk.Choices is not { Count: > 0 })\n {\n continue;\n }\n\n foreach (var choice in chunk.Choices)\n {\n var delta = choice.Delta;\n if (!string.IsNullOrEmpty(delta?.Content))\n {\n yield return new ChatResponseUpdate(ChatRole.Assistant, delta.Content)\n {\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n RawRepresentation = payload\n };\n }\n\n if (!string.IsNullOrEmpty(delta?.ReasoningContent))\n {\n var reasoningContent = new TextReasoningContent(delta.ReasoningContent)\n {\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"reasoning_content\"] = delta.ReasoningContent\n }\n };\n var reasoningUpdate = new ChatResponseUpdate(ChatRole.Assistant, [reasoningContent])\n {\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n RawRepresentation = payload,\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"reasoning_content\"] = delta.ReasoningContent\n }\n };\n yield return reasoningUpdate;\n }\n\n AccumulateToolCalls(toolCalls, delta?.ToolCalls);\n\n var finishReason = MapFinishReason(choice.FinishReason);\n if (finishReason == ChatFinishReason.ToolCalls && toolCalls.Count > 0)\n {\n emittedToolCalls = true;\n yield return CreateToolCallsUpdate(\n toolCalls,\n responseId,\n messageId,\n modelId,\n createdAt,\n finishReason,\n payload);\n }\n else if (finishReason != null)\n {\n yield return new ChatResponseUpdate\n {\n Role = ChatRole.Assistant,\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n FinishReason = finishReason,\n RawRepresentation = payload\n };\n }\n }\n }\n\n if (!emittedToolCalls && toolCalls.Count > 0)\n {\n yield return CreateToolCallsUpdate(\n toolCalls,\n responseId,\n messageId,\n modelId ?? _model,\n createdAt,\n ChatFinishReason.ToolCalls,\n null);\n }\n }\n\n public object? GetService(Type serviceType, object? serviceKey = null)\n {\n return serviceKey == null && serviceType.IsInstanceOfType(this) ? this : null;\n }\n\n public void Dispose()\n {\n if (_disposeHttpClient)\n {\n _httpClient.Dispose();\n }\n }\n\n private HttpRequestMessage CreateRequest(\n IEnumerable<ChatMessage> messages,\n ChatOptions? options,\n bool stream)\n {\n var requestMessages = BuildMessages(messages);\n PrependInstructions(requestMessages, options?.Instructions);\n\n var body = new JsonObject\n {\n [\"model\"] = options?.ModelId ?? _model,\n [\"stream\"] = stream,\n [\"prompt_cache_key\"] = PromptCacheKey,\n [\"messages\"] = requestMessages\n };\n\n if (stream)\n {\n body[\"stream_options\"] = new JsonObject\n {\n [\"include_usage\"] = true\n };\n }\n\n ApplyCommonOptions(body, options);\n ApplyTools(body, options);\n ApplyRequestOverrides(body, null, _options.ProviderRequestOverridesJson);\n ApplyRequestOverrides(body, null, _options.ModelRequestOverridesJson);\n ApplyThinkingConfig(body, options);\n\n var request = new HttpRequestMessage(\n HttpMethod.Post,\n AppendEndpointPath(_options.Endpoint ?? DefaultEndpoint, \"chat/completions\"))\n {\n Content = new StringContent(body.ToJsonString(JsonOptions), Encoding.UTF8, \"application/json\")\n };\n\n request.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", _apiKey);\n request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(stream ? \"text/event-stream\" : \"application/json\"));\n ApplyRequestOverrides(body, request, _options.ProviderRequestOverridesJson);\n ApplyRequestOverrides(body, request, _options.ModelRequestOverridesJson);\n return request;\n }\n\n private static JsonArray BuildMessages(IEnumerable<ChatMessage> messages)\n {\n var array = new JsonArray();\n foreach (var message in messages)\n {\n var functionResults = message.Contents.OfType<FunctionResultContent>().ToList();\n if (functionResults.Count > 0)\n {\n foreach (var result in functionResults)\n {\n array.Add(new JsonObject\n {\n [\"role\"] = \"tool\",\n [\"tool_call_id\"] = result.CallId,\n [\"content\"] = SerializeToolResult(result.Result)\n });\n }\n\n continue;\n }\n\n var functionCalls = message.Contents.OfType<FunctionCallContent>().ToList();\n var role = ToOpenAIRole(message.Role);\n var node = new JsonObject\n {\n [\"role\"] = role\n };\n\n var text = GetText(message.Contents);\n node[\"content\"] = string.IsNullOrEmpty(text) && functionCalls.Count > 0 ? null : text;\n\n var reasoningContent = GetReasoningContent(message);\n if (role == \"assistant\" && !string.IsNullOrEmpty(reasoningContent))\n {\n node[\"reasoning_content\"] = reasoningContent;\n }\n\n if (functionCalls.Count > 0)\n {\n var toolCalls = new JsonArray();\n foreach (var call in functionCalls)\n {\n toolCalls.Add(new JsonObject\n {\n [\"id\"] = call.CallId,\n [\"type\"] = \"function\",\n [\"function\"] = new JsonObject\n {\n [\"name\"] = call.Name,\n [\"arguments\"] = JsonSerializer.Serialize(call.Arguments, JsonOptions)\n }\n });\n }\n\n node[\"tool_calls\"] = toolCalls;\n }\n\n array.Add(node);\n }\n\n return array;\n }\n\n private static void PrependInstructions(JsonArray messages, string? instructions)\n {\n if (string.IsNullOrWhiteSpace(instructions) || HasSystemMessage(messages))\n {\n return;\n }\n\n messages.Insert(0, new JsonObject\n {\n [\"role\"] = \"system\",\n [\"content\"] = instructions\n });\n }\n\n private static bool HasSystemMessage(JsonArray messages)\n {\n foreach (var message in messages)\n {\n if (message is JsonObject node &&\n node[\"role\"]?.GetValue<string>().Equals(\"system\", StringComparison.OrdinalIgnoreCase) == true)\n {\n return true;\n }\n }\n\n return false;\n }\n\n private static void ApplyCommonOptions(JsonObject body, ChatOptions? options)\n {\n if (options == null)\n {\n return;\n }\n\n if (options.MaxOutputTokens.HasValue)\n {\n body[\"max_tokens\"] = options.MaxOutputTokens.Value;\n }\n\n if (options.Temperature.HasValue)\n {\n body[\"temperature\"] = options.Temperature.Value;\n }\n\n if (options.TopP.HasValue)\n {\n body[\"top_p\"] = options.TopP.Value;\n }\n\n if (options.FrequencyPenalty.HasValue)\n {\n body[\"frequency_penalty\"] = options.FrequencyPenalty.Value;\n }\n\n if (options.PresencePenalty.HasValue)\n {\n body[\"presence_penalty\"] = options.PresencePenalty.Value;\n }\n\n if (options.Seed.HasValue)\n {\n body[\"seed\"] = options.Seed.Value;\n }\n\n if (options.StopSequences is { Count: > 0 })\n {\n var stops = new JsonArray();\n foreach (var stop in options.StopSequences)\n {\n stops.Add(stop);\n }\n\n body[\"stop\"] = stops;\n }\n }\n\n private static void ApplyTools(JsonObject body, ChatOptions? options)\n {\n if (options?.Tools is not { Count: > 0 })\n {\n return;\n }\n\n var tools = new JsonArray();\n foreach (var tool in options.Tools)\n {\n if (tool is not AIFunctionDeclaration function)\n {\n continue;\n }\n\n tools.Add(new JsonObject\n {\n [\"type\"] = \"function\",\n [\"function\"] = new JsonObject\n {\n [\"name\"] = function.Name,\n [\"description\"] = function.Description ?? string.Empty,\n [\"parameters\"] = CloneJsonElement(function.JsonSchema) ?? new JsonObject\n {\n [\"type\"] = \"object\"\n }\n }\n });\n }\n\n if (tools.Count == 0)\n {\n return;\n }\n\n body[\"tools\"] = tools;\n body[\"tool_choice\"] = options.ToolMode switch\n {\n NoneChatToolMode => JsonValue.Create(\"none\"),\n RequiredChatToolMode required when !string.IsNullOrWhiteSpace(required.RequiredFunctionName) => new JsonObject\n {\n [\"type\"] = \"function\",\n [\"function\"] = new JsonObject\n {\n [\"name\"] = required.RequiredFunctionName\n }\n },\n RequiredChatToolMode => JsonValue.Create(\"required\"),\n _ => JsonValue.Create(\"auto\")\n };\n }\n\n private void ApplyThinkingConfig(JsonObject body, ChatOptions? options)\n {\n if (!_options.SupportsThinking || string.IsNullOrWhiteSpace(_options.ThinkingConfigJson))\n {\n return;\n }\n\n var enabled = ResolveThinkingEnabled(options);\n if (enabled == null)\n {\n return;\n }\n\n JsonNode? parsed;\n try\n {\n parsed = JsonNode.Parse(_options.ThinkingConfigJson);\n }\n catch (JsonException)\n {\n return;\n }\n\n if (parsed is not JsonObject config)\n {\n return;\n }\n\n var bodyParamName = enabled.Value ? \"bodyParams\" : \"disabledBodyParams\";\n if (config[bodyParamName] is JsonObject bodyParams)\n {\n MergeJsonObject(body, bodyParams);\n }\n\n if (enabled.Value && config[\"forceTemperature\"] is JsonValue forceTemperature)\n {\n body[\"temperature\"] = forceTemperature.DeepClone();\n }\n }\n\n private bool? ResolveThinkingEnabled(ChatOptions? options)\n {\n if (TryReadBoolean(options?.AdditionalProperties, \"thinkingEnabled\", out var thinkingEnabled) ||\n TryReadBoolean(options?.AdditionalProperties, \"enableThinking\", out thinkingEnabled) ||\n TryReadBoolean(options?.AdditionalProperties, \"enable_thinking\", out thinkingEnabled))\n {\n return thinkingEnabled;\n }\n\n return _options.ThinkingEnabled ?? true;\n }\n\n private static void ApplyRequestOverrides(\n JsonObject body,\n HttpRequestMessage? request,\n string? overridesJson)\n {\n if (string.IsNullOrWhiteSpace(overridesJson))\n {\n return;\n }\n\n JsonNode? parsed;\n try\n {\n parsed = JsonNode.Parse(overridesJson);\n }\n catch (JsonException)\n {\n return;\n }\n\n if (parsed is not JsonObject overrides)\n {\n return;\n }\n\n if (request == null)\n {\n if (overrides[\"bodyParams\"] is JsonObject bodyParams)\n {\n MergeJsonObject(body, bodyParams);\n }\n\n if (overrides[\"body\"] is JsonObject bodyOverride)\n {\n MergeJsonObject(body, bodyOverride);\n }\n\n foreach (var pair in overrides)\n {\n if (pair.Key.Equals(\"headers\", StringComparison.OrdinalIgnoreCase) ||\n pair.Key.Equals(\"bodyParams\", StringComparison.OrdinalIgnoreCase) ||\n pair.Key.Equals(\"body\", StringComparison.OrdinalIgnoreCase))\n {\n continue;\n }\n\n body[pair.Key] = pair.Value?.DeepClone();\n }\n\n return;\n }\n\n if (overrides[\"headers\"] is not JsonObject headers)\n {\n return;\n }\n\n foreach (var header in headers)\n {\n if (header.Value == null)\n {\n continue;\n }\n\n var value = header.Value.GetValueKind() == JsonValueKind.String\n ? header.Value.GetValue<string>()\n : header.Value.ToJsonString(JsonOptions);\n if (string.IsNullOrWhiteSpace(value))\n {\n continue;\n }\n\n if (header.Key.Equals(\"Content-Type\", StringComparison.OrdinalIgnoreCase))\n {\n request.Content?.Headers.Remove(header.Key);\n request.Content?.Headers.TryAddWithoutValidation(header.Key, value);\n }\n else\n {\n request.Headers.Remove(header.Key);\n request.Headers.TryAddWithoutValidation(header.Key, value);\n }\n }\n }\n\n private static ChatMessage CreateAssistantMessage(ChatMessageDto message)\n {\n var contents = new List<AIContent>();\n if (!string.IsNullOrEmpty(message.ReasoningContent))\n {\n contents.Add(new TextReasoningContent(message.ReasoningContent)\n {\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"reasoning_content\"] = message.ReasoningContent\n }\n });\n }\n\n if (!string.IsNullOrEmpty(message.Content))\n {\n contents.Add(new TextContent(message.Content));\n }\n\n if (message.ToolCalls is { Count: > 0 })\n {\n foreach (var toolCall in message.ToolCalls)\n {\n var function = toolCall.Function;\n if (string.IsNullOrWhiteSpace(toolCall.Id) || string.IsNullOrWhiteSpace(function?.Name))\n {\n continue;\n }\n\n contents.Add(new FunctionCallContent(\n toolCall.Id,\n function.Name,\n ParseArguments(function.Arguments)));\n }\n }\n\n return new ChatMessage(ChatRole.Assistant, contents);\n }\n\n private static ChatResponseUpdate CreateToolCallsUpdate(\n Dictionary<int, StreamingToolCallBuilder> toolCalls,\n string? responseId,\n string messageId,\n string? modelId,\n DateTimeOffset? createdAt,\n ChatFinishReason? finishReason,\n string? rawPayload)\n {\n var contents = toolCalls\n .OrderBy(pair => pair.Key)\n .Select(pair => pair.Value)\n .Where(call => !string.IsNullOrWhiteSpace(call.Id) && !string.IsNullOrWhiteSpace(call.Name))\n .Select(call => (AIContent)new FunctionCallContent(\n call.Id!,\n call.Name!,\n ParseArguments(call.Arguments.ToString())))\n .ToList();\n\n return new ChatResponseUpdate(ChatRole.Assistant, contents)\n {\n ResponseId = responseId,\n MessageId = messageId,\n ModelId = modelId,\n CreatedAt = createdAt,\n FinishReason = finishReason,\n RawRepresentation = rawPayload\n };\n }\n\n private static void AccumulateToolCalls(\n Dictionary<int, StreamingToolCallBuilder> toolCalls,\n IReadOnlyList<ToolCallDeltaDto>? deltas)\n {\n if (deltas is not { Count: > 0 })\n {\n return;\n }\n\n for (var position = 0; position < deltas.Count; position++)\n {\n var delta = deltas[position];\n var index = delta.Index ?? position;\n if (!toolCalls.TryGetValue(index, out var builder))\n {\n builder = new StreamingToolCallBuilder();\n toolCalls[index] = builder;\n }\n\n if (!string.IsNullOrWhiteSpace(delta.Id))\n {\n builder.Id = delta.Id;\n }\n\n if (!string.IsNullOrWhiteSpace(delta.Function?.Name))\n {\n builder.Name = delta.Function.Name;\n }\n\n if (!string.IsNullOrEmpty(delta.Function?.Arguments))\n {\n builder.Arguments.Append(delta.Function.Arguments);\n }\n }\n }\n\n private static async IAsyncEnumerable<string> ReadSseDataAsync(\n Stream stream,\n [EnumeratorCancellation] CancellationToken cancellationToken)\n {\n using var reader = new StreamReader(stream, Encoding.UTF8);\n var data = new StringBuilder();\n while (await reader.ReadLineAsync(cancellationToken) is { } line)\n {\n if (line.Length == 0)\n {\n if (data.Length > 0)\n {\n yield return data.ToString();\n data.Clear();\n }\n\n continue;\n }\n\n if (line.StartsWith(':'))\n {\n continue;\n }\n\n if (!line.StartsWith(\"data:\", StringComparison.OrdinalIgnoreCase))\n {\n continue;\n }\n\n if (data.Length > 0)\n {\n data.Append('\\n');\n }\n\n data.Append(line[5..].TrimStart());\n }\n\n if (data.Length > 0)\n {\n yield return data.ToString();\n }\n }\n\n private static UsageDetails? MapUsage(UsageDto? usage)\n {\n if (usage == null)\n {\n return null;\n }\n\n return new UsageDetails\n {\n InputTokenCount = usage.PromptTokens,\n OutputTokenCount = usage.CompletionTokens,\n TotalTokenCount = usage.TotalTokens,\n CachedInputTokenCount = usage.PromptTokensDetails?.CachedTokens,\n ReasoningTokenCount = usage.CompletionTokensDetails?.ReasoningTokens\n };\n }\n\n private static ChatFinishReason? MapFinishReason(string? finishReason)\n {\n return finishReason?.Trim().ToLowerInvariant() switch\n {\n \"stop\" => ChatFinishReason.Stop,\n \"length\" => ChatFinishReason.Length,\n \"tool_calls\" or \"function_call\" => ChatFinishReason.ToolCalls,\n \"content_filter\" => ChatFinishReason.ContentFilter,\n _ => null\n };\n }\n\n private static IDictionary<string, object?> ParseArguments(string? arguments)\n {\n if (string.IsNullOrWhiteSpace(arguments))\n {\n return new Dictionary<string, object?>();\n }\n\n try\n {\n using var document = JsonDocument.Parse(arguments);\n if (document.RootElement.ValueKind != JsonValueKind.Object)\n {\n return new Dictionary<string, object?>\n {\n [\"value\"] = ConvertJsonElement(document.RootElement)\n };\n }\n\n return document.RootElement.EnumerateObject()\n .ToDictionary(\n property => property.Name,\n property => ConvertJsonElement(property.Value));\n }\n catch (JsonException)\n {\n return new Dictionary<string, object?>\n {\n [\"value\"] = arguments\n };\n }\n }\n\n private static object? ConvertJsonElement(JsonElement element)\n {\n return element.ValueKind switch\n {\n JsonValueKind.Object => element.EnumerateObject()\n .ToDictionary(property => property.Name, property => ConvertJsonElement(property.Value)),\n JsonValueKind.Array => element.EnumerateArray().Select(ConvertJsonElement).ToList(),\n JsonValueKind.String => element.GetString(),\n JsonValueKind.Number when element.TryGetInt64(out var longValue) => longValue,\n JsonValueKind.Number when element.TryGetDouble(out var doubleValue) => doubleValue,\n JsonValueKind.True => true,\n JsonValueKind.False => false,\n JsonValueKind.Null => null,\n _ => element.GetRawText()\n };\n }\n\n private static string SerializeToolResult(object? result)\n {\n return result switch\n {\n null => \"null\",\n string text => text,\n JsonElement element => element.GetRawText(),\n JsonNode node => node.ToJsonString(JsonOptions),\n _ => JsonSerializer.Serialize(result, JsonOptions)\n };\n }\n\n private static string GetText(IEnumerable<AIContent> contents)\n {\n return string.Concat(contents.OfType<TextContent>().Select(content => content.Text));\n }\n\n private static string GetReasoningContent(ChatMessage message)\n {\n var reasoning = string.Concat(message.Contents\n .OfType<TextReasoningContent>()\n .Select(content => content.Text));\n if (!string.IsNullOrEmpty(reasoning))\n {\n return reasoning;\n }\n\n if (TryReadString(message.AdditionalProperties, \"reasoning_content\", out var rawReasoning))\n {\n return rawReasoning;\n }\n\n return string.Empty;\n }\n\n private static JsonNode? CloneJsonElement(JsonElement element)\n {\n return element.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null\n ? null\n : JsonNode.Parse(element.GetRawText());\n }\n\n private static void MergeJsonObject(JsonObject target, JsonObject source)\n {\n foreach (var pair in source)\n {\n target[pair.Key] = pair.Value?.DeepClone();\n }\n }\n\n private static bool TryReadBoolean(\n AdditionalPropertiesDictionary? properties,\n string key,\n out bool value)\n {\n value = false;\n if (properties == null || !properties.TryGetValue(key, out var raw))\n {\n return false;\n }\n\n switch (raw)\n {\n case bool boolValue:\n value = boolValue;\n return true;\n case string text when bool.TryParse(text, out var parsed):\n value = parsed;\n return true;\n default:\n return false;\n }\n }\n\n private static bool TryReadString(\n AdditionalPropertiesDictionary? properties,\n string key,\n out string value)\n {\n value = string.Empty;\n if (properties == null || !properties.TryGetValue(key, out var raw))\n {\n return false;\n }\n\n switch (raw)\n {\n case string text:\n value = text;\n return true;\n case JsonElement element when element.ValueKind == JsonValueKind.String:\n value = element.GetString() ?? string.Empty;\n return true;\n case JsonNode node when node.GetValueKind() == JsonValueKind.String:\n value = node.GetValue<string>();\n return true;\n default:\n return false;\n }\n }\n\n private static DateTimeOffset? ConvertCreatedAt(long? unixSeconds)\n {\n return unixSeconds.HasValue ? DateTimeOffset.FromUnixTimeSeconds(unixSeconds.Value) : null;\n }\n\n private static string ToOpenAIRole(ChatRole role)\n {\n if (role == ChatRole.System)\n {\n return \"system\";\n }\n\n if (role == ChatRole.Assistant)\n {\n return \"assistant\";\n }\n\n if (role == ChatRole.Tool)\n {\n return \"tool\";\n }\n\n return \"user\";\n }\n\n private static string NormalizeEndpoint(string? endpoint)\n {\n return string.IsNullOrWhiteSpace(endpoint)\n ? DefaultEndpoint\n : endpoint.TrimEnd('/');\n }\n\n private static string AppendEndpointPath(string baseUrl, string path)\n {\n var trimmed = baseUrl.TrimEnd('/');\n if (trimmed.EndsWith($\"/{path}\", StringComparison.OrdinalIgnoreCase))\n {\n return trimmed;\n }\n\n return $\"{trimmed}/{path}\";\n }\n\n private static bool IsDonePayload(string payload)\n {\n return payload.Equals(\"[DONE]\", StringComparison.OrdinalIgnoreCase) ||\n payload.Equals(\"DONE\", StringComparison.OrdinalIgnoreCase);\n }\n\n private static void EnsureSuccessStatusCode(HttpResponseMessage response, string? content)\n {\n if (response.IsSuccessStatusCode)\n {\n return;\n }\n\n var message = string.IsNullOrWhiteSpace(content)\n ? response.ReasonPhrase\n : TrimForError(content);\n throw new HttpRequestException(\n $\"DeepSeek OpenAI-compatible request failed with HTTP {(int)response.StatusCode}: {message}\",\n null,\n response.StatusCode);\n }\n\n private static string TrimForError(string text)\n {\n var trimmed = text.Trim();\n return trimmed.Length <= 600 ? trimmed : trimmed[..600];\n }\n\n private sealed class StreamingToolCallBuilder\n {\n public string? Id { get; set; }\n public string? Name { get; set; }\n public StringBuilder Arguments { get; } = new();\n }\n\n private sealed class ChatCompletionResponse\n {\n [JsonPropertyName(\"id\")]\n public string? Id { get; init; }\n\n [JsonPropertyName(\"model\")]\n public string? Model { get; init; }\n\n [JsonPropertyName(\"created\")]\n public long? Created { get; init; }\n\n [JsonPropertyName(\"choices\")]\n public List<ChatCompletionChoice>? Choices { get; init; }\n\n [JsonPropertyName(\"usage\")]\n public UsageDto? Usage { get; init; }\n }\n\n private sealed class ChatCompletionChunk\n {\n [JsonPropertyName(\"id\")]\n public string? Id { get; init; }\n\n [JsonPropertyName(\"model\")]\n public string? Model { get; init; }\n\n [JsonPropertyName(\"created\")]\n public long? Created { get; init; }\n\n [JsonPropertyName(\"choices\")]\n public List<ChatCompletionChunkChoice>? Choices { get; init; }\n\n [JsonPropertyName(\"usage\")]\n public UsageDto? Usage { get; init; }\n }\n\n private sealed class ChatCompletionChoice\n {\n [JsonPropertyName(\"message\")]\n public ChatMessageDto? Message { get; init; }\n\n [JsonPropertyName(\"finish_reason\")]\n public string? FinishReason { get; init; }\n }\n\n private sealed class ChatCompletionChunkChoice\n {\n [JsonPropertyName(\"delta\")]\n public ChatDeltaDto? Delta { get; init; }\n\n [JsonPropertyName(\"finish_reason\")]\n public string? FinishReason { get; init; }\n }\n\n private sealed class ChatMessageDto\n {\n [JsonPropertyName(\"content\")]\n public string? Content { get; init; }\n\n [JsonPropertyName(\"reasoning_content\")]\n public string? ReasoningContent { get; init; }\n\n [JsonPropertyName(\"tool_calls\")]\n public List<ToolCallDto>? ToolCalls { get; init; }\n }\n\n private sealed class ChatDeltaDto\n {\n [JsonPropertyName(\"content\")]\n public string? Content { get; init; }\n\n [JsonPropertyName(\"reasoning_content\")]\n public string? ReasoningContent { get; init; }\n\n [JsonPropertyName(\"tool_calls\")]\n public List<ToolCallDeltaDto>? ToolCalls { get; init; }\n }\n\n private sealed class ToolCallDto\n {\n [JsonPropertyName(\"id\")]\n public string? Id { get; init; }\n\n [JsonPropertyName(\"function\")]\n public ToolFunctionDto? Function { get; init; }\n }\n\n private sealed class ToolCallDeltaDto\n {\n [JsonPropertyName(\"index\")]\n public int? Index { get; init; }\n\n [JsonPropertyName(\"id\")]\n public string? Id { get; init; }\n\n [JsonPropertyName(\"function\")]\n public ToolFunctionDto? Function { get; init; }\n }\n\n private sealed class ToolFunctionDto\n {\n [JsonPropertyName(\"name\")]\n public string? Name { get; init; }\n\n [JsonPropertyName(\"arguments\")]\n public string? Arguments { get; init; }\n }\n\n private sealed class UsageDto\n {\n [JsonPropertyName(\"prompt_tokens\")]\n public int? PromptTokens { get; init; }\n\n [JsonPropertyName(\"completion_tokens\")]\n public int? CompletionTokens { get; init; }\n\n [JsonPropertyName(\"total_tokens\")]\n public int? TotalTokens { get; init; }\n\n [JsonPropertyName(\"prompt_tokens_details\")]\n public PromptTokensDetailsDto? PromptTokensDetails { get; init; }\n\n [JsonPropertyName(\"completion_tokens_details\")]\n public CompletionTokensDetailsDto? CompletionTokensDetails { get; init; }\n }\n\n private sealed class PromptTokensDetailsDto\n {\n [JsonPropertyName(\"cached_tokens\")]\n public int? CachedTokens { get; init; }\n }\n\n private sealed class CompletionTokensDetailsDto\n {\n [JsonPropertyName(\"reasoning_tokens\")]\n public int? ReasoningTokens { get; init; }\n }\n}\n"}}, "src/OpenDeepWiki/Services/Admin/AdminToolsService.cs": {"6eb5222d25487362fcd411891fad9027f7840f96": {"t": 1785746196.4260066, "n": 49984, "h": "9cd5954fab8fa4b9", "c": "using System.IO.Compression;\nusing System.Net.Http.Headers;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.Json.Nodes;\nusing System.Text.RegularExpressions;\nusing System.Diagnostics;\nusing Microsoft.EntityFrameworkCore;\nusing OpenDeepWiki.EFCore;\nusing OpenDeepWiki.Entities;\nusing OpenDeepWiki.Entities.Tools;\nusing OpenDeepWiki.Models.Admin;\nusing OpenDeepWiki.Services.AI;\nusing OpenDeepWiki.Agents;\nusing YamlDotNet.Serialization;\nusing YamlDotNet.Serialization.NamingConventions;\nusing System.Globalization;\n\nnamespace OpenDeepWiki.Services.Admin;\n\npublic class AdminToolsService : IAdminToolsService\n{\n private readonly IContext _context;\n private readonly ILogger<AdminToolsService> _logger;\n private readonly IHttpClientFactory _httpClientFactory;\n private readonly string _skillsBasePath;\n\n public AdminToolsService(\n IContext context,\n ILogger<AdminToolsService> logger,\n IConfiguration configuration,\n IHttpClientFactory httpClientFactory)\n {\n _context = context;\n _logger = logger;\n _httpClientFactory = httpClientFactory;\n _skillsBasePath = configuration[\"Skills:BasePath\"] ?? Path.Combine(AppContext.BaseDirectory, \"skills\");\n if (!Directory.Exists(_skillsBasePath)) Directory.CreateDirectory(_skillsBasePath);\n }\n\n public async Task<List<McpConfigDto>> GetMcpConfigsAsync()\n {\n return await _context.McpConfigs.Where(m => !m.IsDeleted).OrderBy(m => m.SortOrder)\n .Select(m => new McpConfigDto\n {\n Id = m.Id, Name = m.Name, Description = m.Description, ServerUrl = m.ServerUrl,\n HasApiKey = !string.IsNullOrEmpty(m.ApiKey), IsActive = m.IsActive,\n SortOrder = m.SortOrder, CreatedAt = m.CreatedAt\n }).ToListAsync();\n }\n\n public async Task<McpConfigDto> CreateMcpConfigAsync(McpConfigRequest request)\n {\n var config = new McpConfig\n {\n Id = Guid.NewGuid().ToString(), Name = request.Name, Description = request.Description,\n ServerUrl = request.ServerUrl, ApiKey = request.ApiKey, IsActive = request.IsActive,\n SortOrder = request.SortOrder, CreatedAt = DateTime.UtcNow\n };\n _context.McpConfigs.Add(config);\n await _context.SaveChangesAsync();\n return new McpConfigDto\n {\n Id = config.Id, Name = config.Name, Description = config.Description,\n ServerUrl = config.ServerUrl, HasApiKey = !string.IsNullOrEmpty(config.ApiKey),\n IsActive = config.IsActive, SortOrder = config.SortOrder, CreatedAt = config.CreatedAt\n };\n }\n\n\n public async Task<bool> UpdateMcpConfigAsync(string id, McpConfigRequest request)\n {\n var config = await _context.McpConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.Name = request.Name; config.Description = request.Description;\n config.ServerUrl = request.ServerUrl;\n if (request.ApiKey != null) config.ApiKey = request.ApiKey;\n config.IsActive = request.IsActive; config.SortOrder = request.SortOrder;\n config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteMcpConfigAsync(string id)\n {\n var config = await _context.McpConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<List<SkillConfigDto>> GetSkillConfigsAsync()\n {\n var skills = await _context.SkillConfigs\n .Where(s => !s.IsDeleted)\n .OrderBy(s => s.SortOrder)\n .ThenBy(s => s.Name)\n .ToListAsync();\n\n var result = new List<SkillConfigDto>(skills.Count);\n foreach (var skill in skills)\n {\n var dto = MapSkillConfig(skill);\n dto.Frontmatter = await LoadSkillFrontmatterAsync(skill);\n result.Add(dto);\n }\n\n return result;\n }\n\n public async Task<SkillDetailDto?> GetSkillDetailAsync(string id)\n {\n var skill = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (skill == null) return null;\n var skillPath = Path.Combine(_skillsBasePath, skill.FolderPath);\n var detail = new SkillDetailDto(MapSkillConfig(skill));\n detail.Frontmatter = await LoadSkillFrontmatterAsync(skill);\n var skillMdPath = Path.Combine(skillPath, \"SKILL.md\");\n if (File.Exists(skillMdPath)) detail.SkillMdContent = await File.ReadAllTextAsync(skillMdPath);\n detail.Scripts = ListDirectoryFiles(Path.Combine(skillPath, \"scripts\"));\n detail.References = ListDirectoryFiles(Path.Combine(skillPath, \"references\"));\n detail.Assets = ListDirectoryFiles(Path.Combine(skillPath, \"assets\"));\n return detail;\n }\n\n private SkillConfigDto MapSkillConfig(SkillConfig skill)\n {\n return new SkillConfigDto\n {\n Id = skill.Id,\n Name = skill.Name,\n Description = skill.Description,\n License = skill.License,\n Compatibility = skill.Compatibility,\n AllowedTools = skill.AllowedTools,\n FolderPath = skill.FolderPath,\n IsActive = skill.IsActive,\n SortOrder = skill.SortOrder,\n Author = skill.Author,\n Version = skill.Version,\n Source = skill.Source.ToString().ToLower(),\n SourceUrl = skill.SourceUrl,\n HasScripts = skill.HasScripts,\n HasReferences = skill.HasReferences,\n HasAssets = skill.HasAssets,\n SkillMdSize = skill.SkillMdSize,\n TotalSize = skill.TotalSize,\n CreatedAt = skill.CreatedAt\n };\n }\n\n private async Task<Dictionary<string, object?>> LoadSkillFrontmatterAsync(SkillConfig skill)\n {\n try\n {\n var skillMdPath = Path.Combine(_skillsBasePath, skill.FolderPath, \"SKILL.md\");\n if (!File.Exists(skillMdPath))\n {\n return new Dictionary<string, object?>();\n }\n\n var content = await File.ReadAllTextAsync(skillMdPath);\n var (frontmatter, _) = ParseSkillMd(content);\n return frontmatter;\n }\n catch (Exception ex)\n {\n _logger.LogWarning(ex, \"Failed to load SKILL.md frontmatter for {Skill}\", skill.Name);\n return new Dictionary<string, object?>();\n }\n }\n\n\n public async Task<SkillConfigDto> UploadSkillAsync(Stream zipStream, string fileName)\n {\n var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());\n Directory.CreateDirectory(tempDir);\n try\n {\n using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Read))\n archive.ExtractToDirectory(tempDir);\n var skillMdPath = FindSkillMd(tempDir) ?? throw new InvalidOperationException(\"压缩包中未找到 SKILL.md\");\n var skillRootDir = Path.GetDirectoryName(skillMdPath)!;\n var (frontmatter, _) = ParseSkillMd(await File.ReadAllTextAsync(skillMdPath));\n if (!frontmatter.TryGetValue(\"name\", out var nameObj) || string.IsNullOrEmpty(nameObj?.ToString()))\n throw new InvalidOperationException(\"SKILL.md 缺少 name 字段\");\n var name = nameObj.ToString()!;\n if (!Regex.IsMatch(name, @\"^[a-z0-9]+(-[a-z0-9]+)*$\"))\n throw new InvalidOperationException(\"name 格式无效\");\n if (!frontmatter.TryGetValue(\"description\", out var descObj) || string.IsNullOrEmpty(descObj?.ToString()))\n throw new InvalidOperationException(\"SKILL.md 缺少 description 字段\");\n if (await _context.SkillConfigs.AnyAsync(s => s.Name == name && !s.IsDeleted))\n throw new InvalidOperationException($\"已存在同名 Skill: {name}\");\n var targetPath = Path.Combine(_skillsBasePath, name);\n if (Directory.Exists(targetPath)) Directory.Delete(targetPath, true);\n Directory.Move(skillRootDir, targetPath);\n var config = new SkillConfig\n {\n Id = Guid.NewGuid().ToString(), Name = name, Description = descObj.ToString()!,\n License = frontmatter.TryGetValue(\"license\", out var l) ? l?.ToString() : null,\n Compatibility = frontmatter.TryGetValue(\"compatibility\", out var c) ? c?.ToString() : null,\n AllowedTools = frontmatter.TryGetValue(\"allowed-tools\", out var t) ? t?.ToString() : null,\n FolderPath = name, IsActive = true, SortOrder = 0, Version = \"1.0.0\", Source = SkillSource.Local,\n HasScripts = Directory.Exists(Path.Combine(targetPath, \"scripts\")),\n HasReferences = Directory.Exists(Path.Combine(targetPath, \"references\")),\n HasAssets = Directory.Exists(Path.Combine(targetPath, \"assets\")),\n SkillMdSize = new FileInfo(Path.Combine(targetPath, \"SKILL.md\")).Length,\n TotalSize = CalculateDirectorySize(targetPath), CreatedAt = DateTime.UtcNow\n };\n if (frontmatter.TryGetValue(\"metadata\", out var meta) && meta is Dictionary<object, object> metaDict)\n {\n if (metaDict.TryGetValue(\"author\", out var a)) config.Author = a?.ToString();\n if (metaDict.TryGetValue(\"version\", out var v)) config.Version = v?.ToString() ?? \"1.0.0\";\n }\n _context.SkillConfigs.Add(config);\n await _context.SaveChangesAsync();\n _logger.LogInformation(\"上传 Skill: {Name}\", name);\n return new SkillConfigDto\n {\n Id = config.Id, Name = config.Name, Description = config.Description, License = config.License,\n Compatibility = config.Compatibility, AllowedTools = config.AllowedTools, FolderPath = config.FolderPath,\n IsActive = config.IsActive, SortOrder = config.SortOrder, Author = config.Author, Version = config.Version,\n Source = config.Source.ToString().ToLower(), HasScripts = config.HasScripts,\n HasReferences = config.HasReferences, HasAssets = config.HasAssets,\n SkillMdSize = config.SkillMdSize, TotalSize = config.TotalSize, CreatedAt = config.CreatedAt\n };\n }\n finally { if (Directory.Exists(tempDir)) try { Directory.Delete(tempDir, true); } catch { } }\n }\n\n public async Task<bool> UpdateSkillAsync(string id, SkillUpdateRequest request)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return false;\n if (request.IsActive.HasValue) config.IsActive = request.IsActive.Value;\n if (request.SortOrder.HasValue) config.SortOrder = request.SortOrder.Value;\n config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteSkillAsync(string id)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return false;\n var skillPath = Path.Combine(_skillsBasePath, config.FolderPath);\n if (Directory.Exists(skillPath)) Directory.Delete(skillPath, true);\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n _logger.LogInformation(\"删除 Skill: {Name}\", config.Name);\n return true;\n }\n\n public async Task<string?> GetSkillFileContentAsync(string id, string filePath)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return null;\n var normalizedPath = Path.GetFullPath(Path.Combine(_skillsBasePath, config.FolderPath, filePath));\n var skillBasePath = Path.GetFullPath(Path.Combine(_skillsBasePath, config.FolderPath));\n if (!normalizedPath.StartsWith(skillBasePath)) throw new UnauthorizedAccessException(\"非法路径\");\n return File.Exists(normalizedPath) ? await File.ReadAllTextAsync(normalizedPath) : null;\n }\n\n\n public async Task RefreshSkillsFromDiskAsync()\n {\n if (!Directory.Exists(_skillsBasePath)) return;\n var existingNames = (await _context.SkillConfigs.Where(s => !s.IsDeleted).ToListAsync()).Select(s => s.Name).ToHashSet();\n foreach (var dir in Directory.GetDirectories(_skillsBasePath))\n {\n var skillMdPath = Path.Combine(dir, \"SKILL.md\");\n if (!File.Exists(skillMdPath)) continue;\n var folderName = Path.GetFileName(dir);\n if (existingNames.Contains(folderName)) continue;\n try\n {\n var (frontmatter, _) = ParseSkillMd(await File.ReadAllTextAsync(skillMdPath));\n if (!frontmatter.TryGetValue(\"name\", out var nameObj)) continue;\n var name = nameObj?.ToString();\n if (string.IsNullOrEmpty(name) || name != folderName) continue;\n if (!frontmatter.TryGetValue(\"description\", out var descObj)) continue;\n var config = new SkillConfig\n {\n Id = Guid.NewGuid().ToString(), Name = name, Description = descObj?.ToString() ?? \"\",\n License = frontmatter.TryGetValue(\"license\", out var l) ? l?.ToString() : null,\n Compatibility = frontmatter.TryGetValue(\"compatibility\", out var c) ? c?.ToString() : null,\n AllowedTools = frontmatter.TryGetValue(\"allowed-tools\", out var t) ? t?.ToString() : null,\n FolderPath = folderName, IsActive = true,\n HasScripts = Directory.Exists(Path.Combine(dir, \"scripts\")),\n HasReferences = Directory.Exists(Path.Combine(dir, \"references\")),\n HasAssets = Directory.Exists(Path.Combine(dir, \"assets\")),\n SkillMdSize = new FileInfo(skillMdPath).Length,\n TotalSize = CalculateDirectorySize(dir), CreatedAt = DateTime.UtcNow\n };\n _context.SkillConfigs.Add(config);\n _logger.LogInformation(\"发现 Skill: {Name}\", name);\n }\n catch (Exception ex) { _logger.LogWarning(ex, \"解析失败: {Path}\", dir); }\n }\n await _context.SaveChangesAsync();\n }\n\n public async Task<List<AiProviderConfigDto>> GetAiProvidersAsync()\n {\n return await _context.AiProviderConfigs\n .Where(p => !p.IsDeleted)\n .OrderBy(p => p.SortOrder)\n .ThenBy(p => p.Name)\n .Select(p => new AiProviderConfigDto\n {\n Id = p.Id,\n Name = p.Name,\n DisplayName = p.DisplayName,\n ProviderType = p.ProviderType,\n BaseUrl = p.BaseUrl,\n HasApiKey = !string.IsNullOrEmpty(p.ApiKey),\n AuthType = p.AuthType,\n IsBuiltIn = p.IsBuiltIn,\n IsActive = p.IsActive,\n SupportsModelDiscovery = p.SupportsModelDiscovery,\n ModelsEndpoint = p.ModelsEndpoint,\n DefaultModelId = p.DefaultModelId,\n SystemProxyUrl = p.SystemProxyUrl,\n OAuthConfigJson = p.OAuthConfigJson,\n ChannelConfigJson = p.ChannelConfigJson,\n AccountsJson = p.AccountsJson,\n RequestOverridesJson = p.RequestOverridesJson,\n IconUrl = p.IconUrl,\n Description = p.Description,\n SortOrder = p.SortOrder,\n CreatedAt = p.CreatedAt\n })\n .ToListAsync();\n }\n\n public async Task<AiProviderConfigDto> CreateAiProviderAsync(AiProviderConfigRequest request)\n {\n var provider = new AiProviderConfig\n {\n Id = Guid.NewGuid().ToString(),\n Name = request.Name,\n DisplayName = request.DisplayName,\n ProviderType = request.ProviderType,\n BaseUrl = request.BaseUrl.TrimEnd('/'),\n ApiKey = request.ApiKey,\n AuthType = request.AuthType,\n IsBuiltIn = request.IsBuiltIn,\n IsActive = request.IsActive,\n SupportsModelDiscovery = request.SupportsModelDiscovery,\n ModelsEndpoint = request.ModelsEndpoint,\n DefaultModelId = request.DefaultModelId,\n SystemProxyUrl = request.SystemProxyUrl,\n OAuthConfigJson = request.OAuthConfigJson,\n ChannelConfigJson = request.ChannelConfigJson,\n AccountsJson = request.AccountsJson,\n RequestOverridesJson = request.RequestOverridesJson,\n IconUrl = request.IconUrl,\n Description = request.Description,\n SortOrder = request.SortOrder,\n CreatedAt = DateTime.UtcNow\n };\n\n _context.AiProviderConfigs.Add(provider);\n await _context.SaveChangesAsync();\n return MapAiProvider(provider);\n }\n\n public async Task<bool> UpdateAiProviderAsync(string id, AiProviderConfigRequest request)\n {\n var provider = await _context.AiProviderConfigs.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted);\n if (provider == null) return false;\n\n provider.Name = request.Name;\n provider.DisplayName = request.DisplayName;\n provider.ProviderType = request.ProviderType;\n provider.BaseUrl = request.BaseUrl.TrimEnd('/');\n if (request.ApiKey != null) provider.ApiKey = request.ApiKey;\n provider.AuthType = request.AuthType;\n provider.IsBuiltIn = request.IsBuiltIn;\n provider.IsActive = request.IsActive;\n provider.SupportsModelDiscovery = request.SupportsModelDiscovery;\n provider.ModelsEndpoint = request.ModelsEndpoint;\n provider.DefaultModelId = request.DefaultModelId;\n provider.SystemProxyUrl = request.SystemProxyUrl;\n provider.OAuthConfigJson = request.OAuthConfigJson;\n provider.ChannelConfigJson = request.ChannelConfigJson;\n provider.AccountsJson = request.AccountsJson;\n provider.RequestOverridesJson = request.RequestOverridesJson;\n provider.IconUrl = request.IconUrl;\n provider.Description = request.Description;\n provider.SortOrder = request.SortOrder;\n provider.UpdatedAt = DateTime.UtcNow;\n\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteAiProviderAsync(string id)\n {\n var provider = await _context.AiProviderConfigs.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted);\n if (provider == null) return false;\n provider.IsDeleted = true;\n provider.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<List<AiModelConfigDto>> GetAiModelsAsync(string? providerId = null)\n {\n var query = _context.AiModelConfigs.Where(m => !m.IsDeleted);\n if (!string.IsNullOrWhiteSpace(providerId))\n {\n query = query.Where(m => m.ProviderId == providerId);\n }\n\n var models = await query\n .OrderByDescending(m => m.IsDefault)\n .ThenBy(m => m.SortOrder)\n .ThenBy(m => m.Name)\n .ToListAsync();\n\n var providerIds = models.Select(m => m.ProviderId).Distinct().ToList();\n var providers = providerIds.Count == 0\n ? new Dictionary<string, string>()\n : await _context.AiProviderConfigs\n .Where(p => providerIds.Contains(p.Id) && !p.IsDeleted)\n .ToDictionaryAsync(p => p.Id, p => p.DisplayName ?? p.Name);\n\n return models.Select(m => MapAiModel(m, providers.GetValueOrDefault(m.ProviderId))).ToList();\n }\n\n public async Task<AiModelConfigDto> CreateAiModelAsync(AiModelConfigRequest request)\n {\n var model = new AiModelConfig\n {\n Id = Guid.NewGuid().ToString(),\n ProviderId = request.ProviderId,\n ModelId = request.ModelId,\n Name = string.IsNullOrWhiteSpace(request.Name) ? request.ModelId : request.Name,\n DisplayName = request.DisplayName,\n ModelType = request.ModelType,\n ProviderType = AiProviderResolver.NormalizeModelProviderType(request.ProviderType, request.ModelId),\n ContextWindow = request.ContextWindow,\n MaxOutputTokens = request.MaxOutputTokens,\n InputTokenPrice = request.InputTokenPrice,\n OutputTokenPrice = request.OutputTokenPrice,\n CacheHitTokenPrice = request.CacheHitTokenPrice,\n CacheCreationTokenPrice = request.CacheCreationTokenPrice,\n SupportsThinking = request.SupportsThinking,\n SupportsVision = request.SupportsVision,\n SupportsTools = request.SupportsTools,\n SupportsJsonMode = request.SupportsJsonMode,\n IsDefault = request.IsDefault,\n IsActive = request.IsActive,\n CapabilitiesJson = request.CapabilitiesJson,\n ThinkingConfigJson = request.ThinkingConfigJson,\n RequestOverridesJson = request.RequestOverridesJson,\n TagsJson = request.TagsJson,\n Description = request.Description,\n SortOrder = request.SortOrder,\n CreatedAt = DateTime.UtcNow\n };\n\n _context.AiModelConfigs.Add(model);\n await _context.SaveChangesAsync();\n return MapAiModel(model, null);\n }\n\n public async Task<bool> UpdateAiModelAsync(string id, AiModelConfigRequest request)\n {\n var model = await _context.AiModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (model == null) return false;\n\n model.ProviderId = request.ProviderId;\n model.ModelId = request.ModelId;\n model.Name = string.IsNullOrWhiteSpace(request.Name) ? request.ModelId : request.Name;\n model.DisplayName = request.DisplayName;\n model.ModelType = request.ModelType;\n model.ProviderType = AiProviderResolver.NormalizeModelProviderType(request.ProviderType, request.ModelId);\n model.ContextWindow = request.ContextWindow;\n model.MaxOutputTokens = request.MaxOutputTokens;\n model.InputTokenPrice = request.InputTokenPrice;\n model.OutputTokenPrice = request.OutputTokenPrice;\n model.CacheHitTokenPrice = request.CacheHitTokenPrice;\n model.CacheCreationTokenPrice = request.CacheCreationTokenPrice;\n model.SupportsThinking = request.SupportsThinking;\n model.SupportsVision = request.SupportsVision;\n model.SupportsTools = request.SupportsTools;\n model.SupportsJsonMode = request.SupportsJsonMode;\n model.IsDefault = request.IsDefault;\n model.IsActive = request.IsActive;\n model.CapabilitiesJson = request.CapabilitiesJson;\n model.ThinkingConfigJson = request.ThinkingConfigJson;\n model.RequestOverridesJson = request.RequestOverridesJson;\n model.TagsJson = request.TagsJson;\n model.Description = request.Description;\n model.SortOrder = request.SortOrder;\n model.UpdatedAt = DateTime.UtcNow;\n\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteAiModelAsync(string id)\n {\n var model = await _context.AiModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (model == null) return false;\n model.IsDeleted = true;\n model.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<List<AiModelConfigDto>> DiscoverAiModelsAsync(\n string providerId,\n CancellationToken cancellationToken = default)\n {\n var provider = await _context.AiProviderConfigs\n .FirstOrDefaultAsync(p => p.Id == providerId && !p.IsDeleted, cancellationToken);\n if (provider == null)\n {\n throw new InvalidOperationException(\"AI provider does not exist.\");\n }\n\n var endpoint = string.IsNullOrWhiteSpace(provider.ModelsEndpoint)\n ? $\"{provider.BaseUrl.TrimEnd('/')}/models\"\n : provider.ModelsEndpoint;\n\n var client = _httpClientFactory.CreateClient();\n using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);\n if (!string.IsNullOrWhiteSpace(provider.ApiKey))\n {\n if (provider.ProviderType.Equals(\"Anthropic\", StringComparison.OrdinalIgnoreCase))\n {\n request.Headers.Add(\"x-api-key\", provider.ApiKey);\n request.Headers.Add(\"anthropic-version\", \"2023-06-01\");\n }\n else\n {\n request.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", provider.ApiKey);\n }\n }\n\n using var response = await client.SendAsync(request, cancellationToken);\n response.EnsureSuccessStatusCode();\n var json = await response.Content.ReadAsStringAsync(cancellationToken);\n\n using var document = JsonDocument.Parse(json);\n var modelElements = EnumerateModelElements(document.RootElement);\n return modelElements\n .Select(model => new AiModelConfigDto\n {\n ProviderId = provider.Id,\n ProviderName = provider.DisplayName ?? provider.Name,\n ModelId = model.ModelId,\n Name = model.Name ?? model.ModelId,\n DisplayName = model.DisplayName ?? model.Name ?? model.ModelId,\n ModelType = \"chat\",\n ProviderType = AiProviderResolver.NormalizeModelProviderType(null, model.ModelId) ?? provider.ProviderType,\n InputTokenPrice = model.InputTokenPrice,\n OutputTokenPrice = model.OutputTokenPrice,\n CacheHitTokenPrice = model.CacheHitTokenPrice,\n CacheCreationTokenPrice = model.CacheCreationTokenPrice,\n SupportsTools = true,\n IsActive = true\n })\n .ToList();\n }\n\n public async Task<AiProviderConnectivityTestResult> TestAiProviderConnectivityAsync(\n string providerId,\n AiProviderConnectivityTestRequest request,\n CancellationToken cancellationToken = default)\n {\n var provider = await _context.AiProviderConfigs\n .FirstOrDefaultAsync(p => p.Id == providerId && !p.IsDeleted, cancellationToken);\n if (provider == null)\n {\n throw new InvalidOperationException(\"AI provider does not exist.\");\n }\n\n request ??= new AiProviderConnectivityTestRequest();\n var modelId = request.ModelId?.Trim();\n if (string.IsNullOrWhiteSpace(modelId))\n {\n modelId = provider.DefaultModelId;\n }\n\n if (string.IsNullOrWhiteSpace(modelId))\n {\n modelId = await _context.AiModelConfigs\n .Where(m => m.ProviderId == provider.Id && m.IsActive && !m.IsDeleted)\n .OrderByDescending(m => m.IsDefault)\n .ThenBy(m => m.SortOrder)\n .ThenBy(m => m.Name)\n .Select(m => m.ModelId)\n .FirstOrDefaultAsync(cancellationToken);\n }\n\n if (string.IsNullOrWhiteSpace(modelId))\n {\n throw new InvalidOperationException(\"Please select a model before checking connectivity.\");\n }\n\n var model = await _context.AiModelConfigs.FirstOrDefaultAsync(m =>\n m.ProviderId == provider.Id &&\n m.ModelId == modelId &&\n !m.IsDeleted,\n cancellationToken);\n\n var providerType = AiProviderResolver.ResolveEffectiveProviderType(\n string.IsNullOrWhiteSpace(request.ProviderType) ? provider.ProviderType : request.ProviderType,\n model?.ProviderType);\n var baseUrl = NormalizeConnectivityBaseUrl(\n string.IsNullOrWhiteSpace(request.BaseUrl) ? provider.BaseUrl : request.BaseUrl,\n providerType);\n var apiKey = request.ApiKey ?? provider.ApiKey;\n\n using var message = CreateConnectivityRequest(\n providerType,\n baseUrl,\n apiKey,\n modelId,\n model);\n var client = _httpClientFactory.CreateClient();\n using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);\n timeout.CancelAfter(TimeSpan.FromSeconds(30));\n var stopwatch = Stopwatch.StartNew();\n\n using var response = await client.SendAsync(message, timeout.Token);\n stopwatch.Stop();\n var content = await response.Content.ReadAsStringAsync(cancellationToken);\n\n if (response.IsSuccessStatusCode)\n {\n return new AiProviderConnectivityTestResult\n {\n Success = true,\n Message = $\"Connectivity check succeeded for model '{modelId}'.\",\n ProviderType = providerType,\n ModelId = modelId,\n StatusCode = (int)response.StatusCode,\n LatencyMs = stopwatch.ElapsedMilliseconds\n };\n }\n\n var error = string.IsNullOrWhiteSpace(content)\n ? response.ReasonPhrase\n : content.Trim();\n if (error is { Length: > 600 })\n {\n error = error[..600];\n }\n\n return new AiProviderConnectivityTestResult\n {\n Success = false,\n Message = $\"Connectivity check failed with HTTP {(int)response.StatusCode}: {error}\",\n ProviderType = providerType,\n ModelId = modelId,\n StatusCode = (int)response.StatusCode,\n LatencyMs = stopwatch.ElapsedMilliseconds\n };\n }\n\n public async Task<List<ModelConfigDto>> GetModelConfigsAsync()\n {\n var configs = await _context.ModelConfigs\n .Where(m => !m.IsDeleted)\n .OrderByDescending(m => m.IsDefault)\n .ThenBy(m => m.Name)\n .ToListAsync();\n\n var providerIds = configs\n .Where(m => !string.IsNullOrEmpty(m.AiProviderId))\n .Select(m => m.AiProviderId!)\n .Distinct()\n .ToList();\n\n var providers = providerIds.Count == 0\n ? new Dictionary<string, AiProviderConfig>()\n : await _context.AiProviderConfigs\n .Where(p => providerIds.Contains(p.Id) && !p.IsDeleted)\n .ToDictionaryAsync(p => p.Id);\n\n var aiModels = providerIds.Count == 0\n ? new Dictionary<string, AiModelConfig>(StringComparer.OrdinalIgnoreCase)\n : await _context.AiModelConfigs\n .Where(m => !m.IsDeleted && providerIds.Contains(m.ProviderId))\n .ToDictionaryAsync(\n m => CreateModelBindingKey(m.ProviderId, m.ModelId),\n StringComparer.OrdinalIgnoreCase);\n\n return configs.Select(m =>\n {\n providers.TryGetValue(m.AiProviderId ?? string.Empty, out var provider);\n aiModels.TryGetValue(\n CreateModelBindingKey(m.AiProviderId ?? string.Empty, m.ModelId),\n out var aiModel);\n var providerType = AiProviderResolver.ResolveEffectiveProviderType(\n provider?.ProviderType ?? m.Provider,\n aiModel?.ProviderType);\n return new ModelConfigDto\n {\n Id = m.Id,\n Name = m.Name,\n AiProviderId = m.AiProviderId,\n AiProviderName = provider?.DisplayName ?? provider?.Name,\n Provider = providerType,\n ModelId = m.ModelId,\n Endpoint = null,\n HasApiKey = provider != null && !string.IsNullOrEmpty(provider.ApiKey),\n IsDefault = m.IsDefault,\n IsActive = m.IsActive,\n Description = m.Description,\n CreatedAt = m.CreatedAt\n };\n }).ToList();\n }\n\n public async Task<ModelConfigDto> CreateModelConfigAsync(ModelConfigRequest request)\n {\n var provider = !string.IsNullOrWhiteSpace(request.AiProviderId)\n ? await _context.AiProviderConfigs.FirstOrDefaultAsync(p => p.Id == request.AiProviderId && !p.IsDeleted)\n : null;\n var providerType = provider == null\n ? AiProviderResolver.ResolveEffectiveProviderType(request.Provider, null)\n : await ResolveModelProviderTypeAsync(provider, request.ModelId);\n\n var config = new ModelConfig\n {\n Id = Guid.NewGuid().ToString(), Name = request.Name, Provider = providerType,\n AiProviderId = request.AiProviderId, ModelId = request.ModelId, Endpoint = null, ApiKey = null,\n IsDefault = request.IsDefault, IsActive = request.IsActive, Description = request.Description,\n CreatedAt = DateTime.UtcNow\n };\n _context.ModelConfigs.Add(config);\n await _context.SaveChangesAsync();\n return new ModelConfigDto\n {\n Id = config.Id, Name = config.Name, AiProviderId = config.AiProviderId,\n AiProviderName = provider?.DisplayName ?? provider?.Name, Provider = providerType,\n ModelId = config.ModelId, Endpoint = null, HasApiKey = provider != null && !string.IsNullOrEmpty(provider.ApiKey),\n IsDefault = config.IsDefault, IsActive = config.IsActive, Description = config.Description,\n CreatedAt = config.CreatedAt\n };\n }\n\n public async Task<bool> UpdateModelConfigAsync(string id, ModelConfigRequest request)\n {\n var config = await _context.ModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n var provider = !string.IsNullOrWhiteSpace(request.AiProviderId)\n ? await _context.AiProviderConfigs.FirstOrDefaultAsync(p => p.Id == request.AiProviderId && !p.IsDeleted)\n : null;\n var providerType = provider == null\n ? AiProviderResolver.ResolveEffectiveProviderType(request.Provider, null)\n : await ResolveModelProviderTypeAsync(provider, request.ModelId);\n config.Name = request.Name; config.Provider = providerType; config.ModelId = request.ModelId;\n config.AiProviderId = request.AiProviderId;\n config.Endpoint = null;\n config.ApiKey = null;\n config.IsDefault = request.IsDefault; config.IsActive = request.IsActive;\n config.Description = request.Description; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteModelConfigAsync(string id)\n {\n var config = await _context.ModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n private async Task<string> ResolveModelProviderTypeAsync(AiProviderConfig provider, string modelId)\n {\n var model = await _context.AiModelConfigs.FirstOrDefaultAsync(m =>\n m.ProviderId == provider.Id &&\n m.ModelId == modelId &&\n !m.IsDeleted);\n\n return AiProviderResolver.ResolveEffectiveProviderType(\n provider.ProviderType,\n model?.ProviderType);\n }\n\n private static HttpRequestMessage CreateConnectivityRequest(\n string providerType,\n string baseUrl,\n string? apiKey,\n string modelId,\n AiModelConfig? model)\n {\n var requestType = AiProviderResolver.ParseRequestType(providerType);\n return requestType switch\n {\n AiRequestType.OpenAIResponses => CreateJsonRequest(\n HttpMethod.Post,\n AppendEndpointPath(baseUrl, \"responses\", \"responses\"),\n apiKey,\n new\n {\n model = modelId,\n input = \"ping\",\n max_output_tokens = 1,\n stream = false\n }),\n AiRequestType.Anthropic => CreateJsonRequest(\n HttpMethod.Post,\n AppendEndpointPath(baseUrl, \"v1/messages\", \"messages\"),\n apiKey,\n new\n {\n model = modelId,\n max_tokens = 1,\n messages = new[]\n {\n new { role = \"user\", content = \"ping\" }\n }\n },\n configure: message =>\n {\n if (!string.IsNullOrWhiteSpace(apiKey))\n {\n message.Headers.Remove(\"Authorization\");\n message.Headers.Add(\"x-api-key\", apiKey);\n }\n\n message.Headers.Add(\"anthropic-version\", \"2023-06-01\");\n }),\n AiRequestType.DeepSeekOpenAI => CreateJsonRequest(\n HttpMethod.Post,\n AppendEndpointPath(baseUrl, \"chat/completions\", \"chat/completions\"),\n apiKey,\n CreateDeepSeekConnectivityBody(modelId, model)),\n AiRequestType.AzureOpenAI => CreateAzureOpenAIConnectivityRequest(baseUrl, apiKey, modelId),\n _ => CreateJsonRequest(\n HttpMethod.Post,\n AppendEndpointPath(baseUrl, \"chat/completions\", \"chat/completions\"),\n apiKey,\n new\n {\n model = modelId,\n max_tokens = 1,\n stream = false,\n messages = new[]\n {\n new { role = \"user\", content = \"ping\" }\n }\n })\n };\n }\n\n private static JsonObject CreateDeepSeekConnectivityBody(string modelId, AiModelConfig? model)\n {\n var body = new JsonObject\n {\n [\"model\"] = modelId,\n [\"max_tokens\"] = 1,\n [\"stream\"] = false,\n [\"messages\"] = new JsonArray\n {\n new JsonObject\n {\n [\"role\"] = \"user\",\n [\"content\"] = \"ping\"\n }\n }\n };\n\n if (model?.SupportsThinking == true &&\n !string.IsNullOrWhiteSpace(model.ThinkingConfigJson))\n {\n try\n {\n if (JsonNode.Parse(model.ThinkingConfigJson) is JsonObject config &&\n config[\"bodyParams\"] is JsonObject bodyParams)\n {\n foreach (var pair in bodyParams)\n {\n body[pair.Key] = pair.Value?.DeepClone();\n }\n }\n }\n catch (JsonException)\n {\n // Connectivity checks should still verify the base protocol if model metadata is malformed.\n }\n }\n\n return body;\n }\n\n private static HttpRequestMessage CreateAzureOpenAIConnectivityRequest(\n string baseUrl,\n string? apiKey,\n string modelId)\n {\n var endpoint = baseUrl.Contains(\"/chat/completions\", StringComparison.OrdinalIgnoreCase)\n ? baseUrl\n : $\"{baseUrl.TrimEnd('/')}/openai/deployments/{Uri.EscapeDataString(modelId)}/chat/completions?api-version=2024-10-21\";\n return CreateJsonRequest(\n HttpMethod.Post,\n endpoint,\n apiKey,\n new\n {\n max_tokens = 1,\n stream = false,\n messages = new[]\n {\n new { role = \"user\", content = \"ping\" }\n }\n },\n message =>\n {\n message.Headers.Remove(\"Authorization\");\n if (!string.IsNullOrWhiteSpace(apiKey))\n {\n message.Headers.Add(\"api-key\", apiKey);\n }\n });\n }\n\n private static HttpRequestMessage CreateJsonRequest(\n HttpMethod method,\n string endpoint,\n string? apiKey,\n object body,\n Action<HttpRequestMessage>? configure = null)\n {\n var message = new HttpRequestMessage(method, endpoint)\n {\n Content = new StringContent(\n JsonSerializer.Serialize(body),\n Encoding.UTF8,\n \"application/json\")\n };\n\n if (!string.IsNullOrWhiteSpace(apiKey))\n {\n message.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", apiKey);\n }\n\n configure?.Invoke(message);\n return message;\n }\n\n private static string NormalizeConnectivityBaseUrl(string? baseUrl, string providerType)\n {\n if (!string.IsNullOrWhiteSpace(baseUrl))\n {\n var normalized = baseUrl.Trim().TrimEnd('/');\n return AiProviderResolver.NormalizeProviderType(providerType).Equals(\"Anthropic\", StringComparison.OrdinalIgnoreCase) &&\n normalized.EndsWith(\"/v1\", StringComparison.OrdinalIgnoreCase)\n ? normalized[..^3]\n : normalized;\n }\n\n return AiProviderResolver.NormalizeProviderType(providerType) switch\n {\n \"Anthropic\" => \"https://api.anthropic.com\",\n \"DeepSeekOpenAI\" => \"https://api.deepseek.com/v1\",\n _ => \"https://api.openai.com/v1\"\n };\n }\n\n private static string AppendEndpointPath(string baseUrl, string path, string terminalPath)\n {\n var trimmed = baseUrl.TrimEnd('/');\n if (trimmed.EndsWith($\"/{terminalPath}\", StringComparison.OrdinalIgnoreCase))\n {\n return trimmed;\n }\n\n return $\"{trimmed}/{path.TrimStart('/')}\";\n }\n\n private static string CreateModelBindingKey(string providerId, string modelId)\n {\n return $\"{providerId}:{modelId}\";\n }\n\n private static AiProviderConfigDto MapAiProvider(AiProviderConfig provider)\n {\n return new AiProviderConfigDto\n {\n Id = provider.Id,\n Name = provider.Name,\n DisplayName = provider.DisplayName,\n ProviderType = provider.ProviderType,\n BaseUrl = provider.BaseUrl,\n HasApiKey = !string.IsNullOrEmpty(provider.ApiKey),\n AuthType = provider.AuthType,\n IsBuiltIn = provider.IsBuiltIn,\n IsActive = provider.IsActive,\n SupportsModelDiscovery = provider.SupportsModelDiscovery,\n ModelsEndpoint = provider.ModelsEndpoint,\n DefaultModelId = provider.DefaultModelId,\n SystemProxyUrl = provider.SystemProxyUrl,\n OAuthConfigJson = provider.OAuthConfigJson,\n ChannelConfigJson = provider.ChannelConfigJson,\n AccountsJson = provider.AccountsJson,\n RequestOverridesJson = provider.RequestOverridesJson,\n IconUrl = provider.IconUrl,\n Description = provider.Description,\n SortOrder = provider.SortOrder,\n CreatedAt = provider.CreatedAt\n };\n }\n\n private static AiModelConfigDto MapAiModel(AiModelConfig model, string? providerName)\n {\n return new AiModelConfigDto\n {\n Id = model.Id,\n ProviderId = model.ProviderId,\n ProviderName = providerName,\n ModelId = model.ModelId,\n Name = model.Name,\n DisplayName = model.DisplayName,\n ModelType = model.ModelType,\n ProviderType = model.ProviderType,\n ContextWindow = model.ContextWindow,\n MaxOutputTokens = model.MaxOutputTokens,\n InputTokenPrice = model.InputTokenPrice,\n OutputTokenPrice = model.OutputTokenPrice,\n CacheHitTokenPrice = model.CacheHitTokenPrice,\n CacheCreationTokenPrice = model.CacheCreationTokenPrice,\n SupportsThinking = model.SupportsThinking,\n SupportsVision = model.SupportsVision,\n SupportsTools = model.SupportsTools,\n SupportsJsonMode = model.SupportsJsonMode,\n IsDefault = model.IsDefault,\n IsActive = model.IsActive,\n CapabilitiesJson = model.CapabilitiesJson,\n ThinkingConfigJson = model.ThinkingConfigJson,\n RequestOverridesJson = model.RequestOverridesJson,\n TagsJson = model.TagsJson,\n Description = model.Description,\n SortOrder = model.SortOrder,\n CreatedAt = model.CreatedAt\n };\n }\n\n private static IReadOnlyList<DiscoveredAiModel> EnumerateModelElements(JsonElement root)\n {\n var source = root;\n if (root.ValueKind == JsonValueKind.Object &&\n root.TryGetProperty(\"data\", out var data) &&\n data.ValueKind == JsonValueKind.Array)\n {\n source = data;\n }\n\n if (source.ValueKind != JsonValueKind.Array)\n {\n return [];\n }\n\n var models = new List<DiscoveredAiModel>();\n var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);\n\n foreach (var item in source.EnumerateArray())\n {\n var model = ParseDiscoveredAiModel(item);\n if (model == null || !seen.Add(model.ModelId))\n {\n continue;\n }\n\n models.Add(model);\n }\n\n return models;\n }\n\n private static DiscoveredAiModel? ParseDiscoveredAiModel(JsonElement item)\n {\n if (item.ValueKind == JsonValueKind.String)\n {\n var stringModelId = item.GetString();\n return string.IsNullOrWhiteSpace(stringModelId)\n ? null\n : new DiscoveredAiModel(stringModelId, null, null, null, null, null, null);\n }\n\n if (item.ValueKind != JsonValueKind.Object)\n {\n return null;\n }\n\n var modelId = ReadOptionalString(item, \"id\");\n if (string.IsNullOrWhiteSpace(modelId))\n {\n return null;\n }\n\n return new DiscoveredAiModel(\n modelId,\n ReadOptionalString(item, \"name\"),\n ReadOptionalString(item, \"displayName\"),\n ReadOptionalDecimal(item, \"inputPrice\") ?? ReadOptionalDecimal(item, \"promptPrice\"),\n ReadOptionalDecimal(item, \"outputPrice\") ?? ReadOptionalDecimal(item, \"completionPrice\"),\n ReadOptionalDecimal(item, \"cacheHitPrice\"),\n ReadOptionalDecimal(item, \"cacheCreationPrice\"));\n }\n\n private static string? ReadOptionalString(JsonElement item, string propertyName)\n {\n return item.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String\n ? value.GetString()\n : null;\n }\n\n private static decimal? ReadOptionalDecimal(JsonElement item, string propertyName)\n {\n if (!item.TryGetProperty(propertyName, out var value))\n {\n return null;\n }\n\n return value.ValueKind switch\n {\n JsonValueKind.Number when value.TryGetDecimal(out var decimalValue) => decimalValue,\n JsonValueKind.String when decimal.TryParse(\n value.GetString(),\n NumberStyles.Number,\n CultureInfo.InvariantCulture,\n out var stringValue) => stringValue,\n _ => null\n };\n }\n\n private sealed record DiscoveredAiModel(\n string ModelId,\n string? Name,\n string? DisplayName,\n decimal? InputTokenPrice,\n decimal? OutputTokenPrice,\n decimal? CacheHitTokenPrice,\n decimal? CacheCreationTokenPrice);\n\n private static string? FindSkillMd(string directory)\n {\n var skillMd = Path.Combine(directory, \"SKILL.md\");\n if (File.Exists(skillMd)) return skillMd;\n foreach (var subDir in Directory.GetDirectories(directory))\n {\n skillMd = Path.Combine(subDir, \"SKILL.md\");\n if (File.Exists(skillMd)) return skillMd;\n }\n return null;\n }\n\n private static (Dictionary<string, object?> frontmatter, string body) ParseSkillMd(string content)\n {\n var frontmatter = new Dictionary<string, object?>();\n var body = content;\n if (content.StartsWith(\"---\"))\n {\n var endIndex = content.IndexOf(\"---\", 3);\n if (endIndex > 0)\n {\n var yamlContent = content[3..endIndex].Trim();\n body = content[(endIndex + 3)..].Trim();\n try\n {\n var deserializer = new DeserializerBuilder().WithNamingConvention(HyphenatedNamingConvention.Instance).Build();\n frontmatter = deserializer.Deserialize<Dictionary<string, object?>>(yamlContent) ?? new();\n }\n catch { }\n }\n }\n return (frontmatter, body);\n }\n\n private static List<SkillFileInfo> ListDirectoryFiles(string directory)\n {\n var files = new List<SkillFileInfo>();\n if (!Directory.Exists(directory)) return files;\n foreach (var file in Directory.GetFiles(directory, \"*\", SearchOption.AllDirectories))\n {\n var info = new FileInfo(file);\n files.Add(new SkillFileInfo { FileName = info.Name, RelativePath = Path.GetRelativePath(directory, file), Size = info.Length, LastModified = info.LastWriteTimeUtc });\n }\n return files;\n }\n\n private static long CalculateDirectorySize(string directory) =>\n !Directory.Exists(directory) ? 0 : Directory.GetFiles(directory, \"*\", SearchOption.AllDirectories).Sum(f => new FileInfo(f).Length);\n}\n"}, "1f5050c03db13081755e60b2ea722654fb95b84c": {"t": 1785760374.55905, "n": 49984, "h": "9cd5954fab8fa4b9", "c": "using System.IO.Compression;\nusing System.Net.Http.Headers;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.Json.Nodes;\nusing System.Text.RegularExpressions;\nusing System.Diagnostics;\nusing Microsoft.EntityFrameworkCore;\nusing OpenDeepWiki.EFCore;\nusing OpenDeepWiki.Entities;\nusing OpenDeepWiki.Entities.Tools;\nusing OpenDeepWiki.Models.Admin;\nusing OpenDeepWiki.Services.AI;\nusing OpenDeepWiki.Agents;\nusing YamlDotNet.Serialization;\nusing YamlDotNet.Serialization.NamingConventions;\nusing System.Globalization;\n\nnamespace OpenDeepWiki.Services.Admin;\n\npublic class AdminToolsService : IAdminToolsService\n{\n private readonly IContext _context;\n private readonly ILogger<AdminToolsService> _logger;\n private readonly IHttpClientFactory _httpClientFactory;\n private readonly string _skillsBasePath;\n\n public AdminToolsService(\n IContext context,\n ILogger<AdminToolsService> logger,\n IConfiguration configuration,\n IHttpClientFactory httpClientFactory)\n {\n _context = context;\n _logger = logger;\n _httpClientFactory = httpClientFactory;\n _skillsBasePath = configuration[\"Skills:BasePath\"] ?? Path.Combine(AppContext.BaseDirectory, \"skills\");\n if (!Directory.Exists(_skillsBasePath)) Directory.CreateDirectory(_skillsBasePath);\n }\n\n public async Task<List<McpConfigDto>> GetMcpConfigsAsync()\n {\n return await _context.McpConfigs.Where(m => !m.IsDeleted).OrderBy(m => m.SortOrder)\n .Select(m => new McpConfigDto\n {\n Id = m.Id, Name = m.Name, Description = m.Description, ServerUrl = m.ServerUrl,\n HasApiKey = !string.IsNullOrEmpty(m.ApiKey), IsActive = m.IsActive,\n SortOrder = m.SortOrder, CreatedAt = m.CreatedAt\n }).ToListAsync();\n }\n\n public async Task<McpConfigDto> CreateMcpConfigAsync(McpConfigRequest request)\n {\n var config = new McpConfig\n {\n Id = Guid.NewGuid().ToString(), Name = request.Name, Description = request.Description,\n ServerUrl = request.ServerUrl, ApiKey = request.ApiKey, IsActive = request.IsActive,\n SortOrder = request.SortOrder, CreatedAt = DateTime.UtcNow\n };\n _context.McpConfigs.Add(config);\n await _context.SaveChangesAsync();\n return new McpConfigDto\n {\n Id = config.Id, Name = config.Name, Description = config.Description,\n ServerUrl = config.ServerUrl, HasApiKey = !string.IsNullOrEmpty(config.ApiKey),\n IsActive = config.IsActive, SortOrder = config.SortOrder, CreatedAt = config.CreatedAt\n };\n }\n\n\n public async Task<bool> UpdateMcpConfigAsync(string id, McpConfigRequest request)\n {\n var config = await _context.McpConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.Name = request.Name; config.Description = request.Description;\n config.ServerUrl = request.ServerUrl;\n if (request.ApiKey != null) config.ApiKey = request.ApiKey;\n config.IsActive = request.IsActive; config.SortOrder = request.SortOrder;\n config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteMcpConfigAsync(string id)\n {\n var config = await _context.McpConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<List<SkillConfigDto>> GetSkillConfigsAsync()\n {\n var skills = await _context.SkillConfigs\n .Where(s => !s.IsDeleted)\n .OrderBy(s => s.SortOrder)\n .ThenBy(s => s.Name)\n .ToListAsync();\n\n var result = new List<SkillConfigDto>(skills.Count);\n foreach (var skill in skills)\n {\n var dto = MapSkillConfig(skill);\n dto.Frontmatter = await LoadSkillFrontmatterAsync(skill);\n result.Add(dto);\n }\n\n return result;\n }\n\n public async Task<SkillDetailDto?> GetSkillDetailAsync(string id)\n {\n var skill = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (skill == null) return null;\n var skillPath = Path.Combine(_skillsBasePath, skill.FolderPath);\n var detail = new SkillDetailDto(MapSkillConfig(skill));\n detail.Frontmatter = await LoadSkillFrontmatterAsync(skill);\n var skillMdPath = Path.Combine(skillPath, \"SKILL.md\");\n if (File.Exists(skillMdPath)) detail.SkillMdContent = await File.ReadAllTextAsync(skillMdPath);\n detail.Scripts = ListDirectoryFiles(Path.Combine(skillPath, \"scripts\"));\n detail.References = ListDirectoryFiles(Path.Combine(skillPath, \"references\"));\n detail.Assets = ListDirectoryFiles(Path.Combine(skillPath, \"assets\"));\n return detail;\n }\n\n private SkillConfigDto MapSkillConfig(SkillConfig skill)\n {\n return new SkillConfigDto\n {\n Id = skill.Id,\n Name = skill.Name,\n Description = skill.Description,\n License = skill.License,\n Compatibility = skill.Compatibility,\n AllowedTools = skill.AllowedTools,\n FolderPath = skill.FolderPath,\n IsActive = skill.IsActive,\n SortOrder = skill.SortOrder,\n Author = skill.Author,\n Version = skill.Version,\n Source = skill.Source.ToString().ToLower(),\n SourceUrl = skill.SourceUrl,\n HasScripts = skill.HasScripts,\n HasReferences = skill.HasReferences,\n HasAssets = skill.HasAssets,\n SkillMdSize = skill.SkillMdSize,\n TotalSize = skill.TotalSize,\n CreatedAt = skill.CreatedAt\n };\n }\n\n private async Task<Dictionary<string, object?>> LoadSkillFrontmatterAsync(SkillConfig skill)\n {\n try\n {\n var skillMdPath = Path.Combine(_skillsBasePath, skill.FolderPath, \"SKILL.md\");\n if (!File.Exists(skillMdPath))\n {\n return new Dictionary<string, object?>();\n }\n\n var content = await File.ReadAllTextAsync(skillMdPath);\n var (frontmatter, _) = ParseSkillMd(content);\n return frontmatter;\n }\n catch (Exception ex)\n {\n _logger.LogWarning(ex, \"Failed to load SKILL.md frontmatter for {Skill}\", skill.Name);\n return new Dictionary<string, object?>();\n }\n }\n\n\n public async Task<SkillConfigDto> UploadSkillAsync(Stream zipStream, string fileName)\n {\n var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());\n Directory.CreateDirectory(tempDir);\n try\n {\n using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Read))\n archive.ExtractToDirectory(tempDir);\n var skillMdPath = FindSkillMd(tempDir) ?? throw new InvalidOperationException(\"压缩包中未找到 SKILL.md\");\n var skillRootDir = Path.GetDirectoryName(skillMdPath)!;\n var (frontmatter, _) = ParseSkillMd(await File.ReadAllTextAsync(skillMdPath));\n if (!frontmatter.TryGetValue(\"name\", out var nameObj) || string.IsNullOrEmpty(nameObj?.ToString()))\n throw new InvalidOperationException(\"SKILL.md 缺少 name 字段\");\n var name = nameObj.ToString()!;\n if (!Regex.IsMatch(name, @\"^[a-z0-9]+(-[a-z0-9]+)*$\"))\n throw new InvalidOperationException(\"name 格式无效\");\n if (!frontmatter.TryGetValue(\"description\", out var descObj) || string.IsNullOrEmpty(descObj?.ToString()))\n throw new InvalidOperationException(\"SKILL.md 缺少 description 字段\");\n if (await _context.SkillConfigs.AnyAsync(s => s.Name == name && !s.IsDeleted))\n throw new InvalidOperationException($\"已存在同名 Skill: {name}\");\n var targetPath = Path.Combine(_skillsBasePath, name);\n if (Directory.Exists(targetPath)) Directory.Delete(targetPath, true);\n Directory.Move(skillRootDir, targetPath);\n var config = new SkillConfig\n {\n Id = Guid.NewGuid().ToString(), Name = name, Description = descObj.ToString()!,\n License = frontmatter.TryGetValue(\"license\", out var l) ? l?.ToString() : null,\n Compatibility = frontmatter.TryGetValue(\"compatibility\", out var c) ? c?.ToString() : null,\n AllowedTools = frontmatter.TryGetValue(\"allowed-tools\", out var t) ? t?.ToString() : null,\n FolderPath = name, IsActive = true, SortOrder = 0, Version = \"1.0.0\", Source = SkillSource.Local,\n HasScripts = Directory.Exists(Path.Combine(targetPath, \"scripts\")),\n HasReferences = Directory.Exists(Path.Combine(targetPath, \"references\")),\n HasAssets = Directory.Exists(Path.Combine(targetPath, \"assets\")),\n SkillMdSize = new FileInfo(Path.Combine(targetPath, \"SKILL.md\")).Length,\n TotalSize = CalculateDirectorySize(targetPath), CreatedAt = DateTime.UtcNow\n };\n if (frontmatter.TryGetValue(\"metadata\", out var meta) && meta is Dictionary<object, object> metaDict)\n {\n if (metaDict.TryGetValue(\"author\", out var a)) config.Author = a?.ToString();\n if (metaDict.TryGetValue(\"version\", out var v)) config.Version = v?.ToString() ?? \"1.0.0\";\n }\n _context.SkillConfigs.Add(config);\n await _context.SaveChangesAsync();\n _logger.LogInformation(\"上传 Skill: {Name}\", name);\n return new SkillConfigDto\n {\n Id = config.Id, Name = config.Name, Description = config.Description, License = config.License,\n Compatibility = config.Compatibility, AllowedTools = config.AllowedTools, FolderPath = config.FolderPath,\n IsActive = config.IsActive, SortOrder = config.SortOrder, Author = config.Author, Version = config.Version,\n Source = config.Source.ToString().ToLower(), HasScripts = config.HasScripts,\n HasReferences = config.HasReferences, HasAssets = config.HasAssets,\n SkillMdSize = config.SkillMdSize, TotalSize = config.TotalSize, CreatedAt = config.CreatedAt\n };\n }\n finally { if (Directory.Exists(tempDir)) try { Directory.Delete(tempDir, true); } catch { } }\n }\n\n public async Task<bool> UpdateSkillAsync(string id, SkillUpdateRequest request)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return false;\n if (request.IsActive.HasValue) config.IsActive = request.IsActive.Value;\n if (request.SortOrder.HasValue) config.SortOrder = request.SortOrder.Value;\n config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteSkillAsync(string id)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return false;\n var skillPath = Path.Combine(_skillsBasePath, config.FolderPath);\n if (Directory.Exists(skillPath)) Directory.Delete(skillPath, true);\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n _logger.LogInformation(\"删除 Skill: {Name}\", config.Name);\n return true;\n }\n\n public async Task<string?> GetSkillFileContentAsync(string id, string filePath)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return null;\n var normalizedPath = Path.GetFullPath(Path.Combine(_skillsBasePath, config.FolderPath, filePath));\n var skillBasePath = Path.GetFullPath(Path.Combine(_skillsBasePath, config.FolderPath));\n if (!normalizedPath.StartsWith(skillBasePath)) throw new UnauthorizedAccessException(\"非法路径\");\n return File.Exists(normalizedPath) ? await File.ReadAllTextAsync(normalizedPath) : null;\n }\n\n\n public async Task RefreshSkillsFromDiskAsync()\n {\n if (!Directory.Exists(_skillsBasePath)) return;\n var existingNames = (await _context.SkillConfigs.Where(s => !s.IsDeleted).ToListAsync()).Select(s => s.Name).ToHashSet();\n foreach (var dir in Directory.GetDirectories(_skillsBasePath))\n {\n var skillMdPath = Path.Combine(dir, \"SKILL.md\");\n if (!File.Exists(skillMdPath)) continue;\n var folderName = Path.GetFileName(dir);\n if (existingNames.Contains(folderName)) continue;\n try\n {\n var (frontmatter, _) = ParseSkillMd(await File.ReadAllTextAsync(skillMdPath));\n if (!frontmatter.TryGetValue(\"name\", out var nameObj)) continue;\n var name = nameObj?.ToString();\n if (string.IsNullOrEmpty(name) || name != folderName) continue;\n if (!frontmatter.TryGetValue(\"description\", out var descObj)) continue;\n var config = new SkillConfig\n {\n Id = Guid.NewGuid().ToString(), Name = name, Description = descObj?.ToString() ?? \"\",\n License = frontmatter.TryGetValue(\"license\", out var l) ? l?.ToString() : null,\n Compatibility = frontmatter.TryGetValue(\"compatibility\", out var c) ? c?.ToString() : null,\n AllowedTools = frontmatter.TryGetValue(\"allowed-tools\", out var t) ? t?.ToString() : null,\n FolderPath = folderName, IsActive = true,\n HasScripts = Directory.Exists(Path.Combine(dir, \"scripts\")),\n HasReferences = Directory.Exists(Path.Combine(dir, \"references\")),\n HasAssets = Directory.Exists(Path.Combine(dir, \"assets\")),\n SkillMdSize = new FileInfo(skillMdPath).Length,\n TotalSize = CalculateDirectorySize(dir), CreatedAt = DateTime.UtcNow\n };\n _context.SkillConfigs.Add(config);\n _logger.LogInformation(\"发现 Skill: {Name}\", name);\n }\n catch (Exception ex) { _logger.LogWarning(ex, \"解析失败: {Path}\", dir); }\n }\n await _context.SaveChangesAsync();\n }\n\n public async Task<List<AiProviderConfigDto>> GetAiProvidersAsync()\n {\n return await _context.AiProviderConfigs\n .Where(p => !p.IsDeleted)\n .OrderBy(p => p.SortOrder)\n .ThenBy(p => p.Name)\n .Select(p => new AiProviderConfigDto\n {\n Id = p.Id,\n Name = p.Name,\n DisplayName = p.DisplayName,\n ProviderType = p.ProviderType,\n BaseUrl = p.BaseUrl,\n HasApiKey = !string.IsNullOrEmpty(p.ApiKey),\n AuthType = p.AuthType,\n IsBuiltIn = p.IsBuiltIn,\n IsActive = p.IsActive,\n SupportsModelDiscovery = p.SupportsModelDiscovery,\n ModelsEndpoint = p.ModelsEndpoint,\n DefaultModelId = p.DefaultModelId,\n SystemProxyUrl = p.SystemProxyUrl,\n OAuthConfigJson = p.OAuthConfigJson,\n ChannelConfigJson = p.ChannelConfigJson,\n AccountsJson = p.AccountsJson,\n RequestOverridesJson = p.RequestOverridesJson,\n IconUrl = p.IconUrl,\n Description = p.Description,\n SortOrder = p.SortOrder,\n CreatedAt = p.CreatedAt\n })\n .ToListAsync();\n }\n\n public async Task<AiProviderConfigDto> CreateAiProviderAsync(AiProviderConfigRequest request)\n {\n var provider = new AiProviderConfig\n {\n Id = Guid.NewGuid().ToString(),\n Name = request.Name,\n DisplayName = request.DisplayName,\n ProviderType = request.ProviderType,\n BaseUrl = request.BaseUrl.TrimEnd('/'),\n ApiKey = request.ApiKey,\n AuthType = request.AuthType,\n IsBuiltIn = request.IsBuiltIn,\n IsActive = request.IsActive,\n SupportsModelDiscovery = request.SupportsModelDiscovery,\n ModelsEndpoint = request.ModelsEndpoint,\n DefaultModelId = request.DefaultModelId,\n SystemProxyUrl = request.SystemProxyUrl,\n OAuthConfigJson = request.OAuthConfigJson,\n ChannelConfigJson = request.ChannelConfigJson,\n AccountsJson = request.AccountsJson,\n RequestOverridesJson = request.RequestOverridesJson,\n IconUrl = request.IconUrl,\n Description = request.Description,\n SortOrder = request.SortOrder,\n CreatedAt = DateTime.UtcNow\n };\n\n _context.AiProviderConfigs.Add(provider);\n await _context.SaveChangesAsync();\n return MapAiProvider(provider);\n }\n\n public async Task<bool> UpdateAiProviderAsync(string id, AiProviderConfigRequest request)\n {\n var provider = await _context.AiProviderConfigs.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted);\n if (provider == null) return false;\n\n provider.Name = request.Name;\n provider.DisplayName = request.DisplayName;\n provider.ProviderType = request.ProviderType;\n provider.BaseUrl = request.BaseUrl.TrimEnd('/');\n if (request.ApiKey != null) provider.ApiKey = request.ApiKey;\n provider.AuthType = request.AuthType;\n provider.IsBuiltIn = request.IsBuiltIn;\n provider.IsActive = request.IsActive;\n provider.SupportsModelDiscovery = request.SupportsModelDiscovery;\n provider.ModelsEndpoint = request.ModelsEndpoint;\n provider.DefaultModelId = request.DefaultModelId;\n provider.SystemProxyUrl = request.SystemProxyUrl;\n provider.OAuthConfigJson = request.OAuthConfigJson;\n provider.ChannelConfigJson = request.ChannelConfigJson;\n provider.AccountsJson = request.AccountsJson;\n provider.RequestOverridesJson = request.RequestOverridesJson;\n provider.IconUrl = request.IconUrl;\n provider.Description = request.Description;\n provider.SortOrder = request.SortOrder;\n provider.UpdatedAt = DateTime.UtcNow;\n\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteAiProviderAsync(string id)\n {\n var provider = await _context.AiProviderConfigs.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted);\n if (provider == null) return false;\n provider.IsDeleted = true;\n provider.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<List<AiModelConfigDto>> GetAiModelsAsync(string? providerId = null)\n {\n var query = _context.AiModelConfigs.Where(m => !m.IsDeleted);\n if (!string.IsNullOrWhiteSpace(providerId))\n {\n query = query.Where(m => m.ProviderId == providerId);\n }\n\n var models = await query\n .OrderByDescending(m => m.IsDefault)\n .ThenBy(m => m.SortOrder)\n .ThenBy(m => m.Name)\n .ToListAsync();\n\n var providerIds = models.Select(m => m.ProviderId).Distinct().ToList();\n var providers = providerIds.Count == 0\n ? new Dictionary<string, string>()\n : await _context.AiProviderConfigs\n .Where(p => providerIds.Contains(p.Id) && !p.IsDeleted)\n .ToDictionaryAsync(p => p.Id, p => p.DisplayName ?? p.Name);\n\n return models.Select(m => MapAiModel(m, providers.GetValueOrDefault(m.ProviderId))).ToList();\n }\n\n public async Task<AiModelConfigDto> CreateAiModelAsync(AiModelConfigRequest request)\n {\n var model = new AiModelConfig\n {\n Id = Guid.NewGuid().ToString(),\n ProviderId = request.ProviderId,\n ModelId = request.ModelId,\n Name = string.IsNullOrWhiteSpace(request.Name) ? request.ModelId : request.Name,\n DisplayName = request.DisplayName,\n ModelType = request.ModelType,\n ProviderType = AiProviderResolver.NormalizeModelProviderType(request.ProviderType, request.ModelId),\n ContextWindow = request.ContextWindow,\n MaxOutputTokens = request.MaxOutputTokens,\n InputTokenPrice = request.InputTokenPrice,\n OutputTokenPrice = request.OutputTokenPrice,\n CacheHitTokenPrice = request.CacheHitTokenPrice,\n CacheCreationTokenPrice = request.CacheCreationTokenPrice,\n SupportsThinking = request.SupportsThinking,\n SupportsVision = request.SupportsVision,\n SupportsTools = request.SupportsTools,\n SupportsJsonMode = request.SupportsJsonMode,\n IsDefault = request.IsDefault,\n IsActive = request.IsActive,\n CapabilitiesJson = request.CapabilitiesJson,\n ThinkingConfigJson = request.ThinkingConfigJson,\n RequestOverridesJson = request.RequestOverridesJson,\n TagsJson = request.TagsJson,\n Description = request.Description,\n SortOrder = request.SortOrder,\n CreatedAt = DateTime.UtcNow\n };\n\n _context.AiModelConfigs.Add(model);\n await _context.SaveChangesAsync();\n return MapAiModel(model, null);\n }\n\n public async Task<bool> UpdateAiModelAsync(string id, AiModelConfigRequest request)\n {\n var model = await _context.AiModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (model == null) return false;\n\n model.ProviderId = request.ProviderId;\n model.ModelId = request.ModelId;\n model.Name = string.IsNullOrWhiteSpace(request.Name) ? request.ModelId : request.Name;\n model.DisplayName = request.DisplayName;\n model.ModelType = request.ModelType;\n model.ProviderType = AiProviderResolver.NormalizeModelProviderType(request.ProviderType, request.ModelId);\n model.ContextWindow = request.ContextWindow;\n model.MaxOutputTokens = request.MaxOutputTokens;\n model.InputTokenPrice = request.InputTokenPrice;\n model.OutputTokenPrice = request.OutputTokenPrice;\n model.CacheHitTokenPrice = request.CacheHitTokenPrice;\n model.CacheCreationTokenPrice = request.CacheCreationTokenPrice;\n model.SupportsThinking = request.SupportsThinking;\n model.SupportsVision = request.SupportsVision;\n model.SupportsTools = request.SupportsTools;\n model.SupportsJsonMode = request.SupportsJsonMode;\n model.IsDefault = request.IsDefault;\n model.IsActive = request.IsActive;\n model.CapabilitiesJson = request.CapabilitiesJson;\n model.ThinkingConfigJson = request.ThinkingConfigJson;\n model.RequestOverridesJson = request.RequestOverridesJson;\n model.TagsJson = request.TagsJson;\n model.Description = request.Description;\n model.SortOrder = request.SortOrder;\n model.UpdatedAt = DateTime.UtcNow;\n\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteAiModelAsync(string id)\n {\n var model = await _context.AiModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (model == null) return false;\n model.IsDeleted = true;\n model.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<List<AiModelConfigDto>> DiscoverAiModelsAsync(\n string providerId,\n CancellationToken cancellationToken = default)\n {\n var provider = await _context.AiProviderConfigs\n .FirstOrDefaultAsync(p => p.Id == providerId && !p.IsDeleted, cancellationToken);\n if (provider == null)\n {\n throw new InvalidOperationException(\"AI provider does not exist.\");\n }\n\n var endpoint = string.IsNullOrWhiteSpace(provider.ModelsEndpoint)\n ? $\"{provider.BaseUrl.TrimEnd('/')}/models\"\n : provider.ModelsEndpoint;\n\n var client = _httpClientFactory.CreateClient();\n using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);\n if (!string.IsNullOrWhiteSpace(provider.ApiKey))\n {\n if (provider.ProviderType.Equals(\"Anthropic\", StringComparison.OrdinalIgnoreCase))\n {\n request.Headers.Add(\"x-api-key\", provider.ApiKey);\n request.Headers.Add(\"anthropic-version\", \"2023-06-01\");\n }\n else\n {\n request.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", provider.ApiKey);\n }\n }\n\n using var response = await client.SendAsync(request, cancellationToken);\n response.EnsureSuccessStatusCode();\n var json = await response.Content.ReadAsStringAsync(cancellationToken);\n\n using var document = JsonDocument.Parse(json);\n var modelElements = EnumerateModelElements(document.RootElement);\n return modelElements\n .Select(model => new AiModelConfigDto\n {\n ProviderId = provider.Id,\n ProviderName = provider.DisplayName ?? provider.Name,\n ModelId = model.ModelId,\n Name = model.Name ?? model.ModelId,\n DisplayName = model.DisplayName ?? model.Name ?? model.ModelId,\n ModelType = \"chat\",\n ProviderType = AiProviderResolver.NormalizeModelProviderType(null, model.ModelId) ?? provider.ProviderType,\n InputTokenPrice = model.InputTokenPrice,\n OutputTokenPrice = model.OutputTokenPrice,\n CacheHitTokenPrice = model.CacheHitTokenPrice,\n CacheCreationTokenPrice = model.CacheCreationTokenPrice,\n SupportsTools = true,\n IsActive = true\n })\n .ToList();\n }\n\n public async Task<AiProviderConnectivityTestResult> TestAiProviderConnectivityAsync(\n string providerId,\n AiProviderConnectivityTestRequest request,\n CancellationToken cancellationToken = default)\n {\n var provider = await _context.AiProviderConfigs\n .FirstOrDefaultAsync(p => p.Id == providerId && !p.IsDeleted, cancellationToken);\n if (provider == null)\n {\n throw new InvalidOperationException(\"AI provider does not exist.\");\n }\n\n request ??= new AiProviderConnectivityTestRequest();\n var modelId = request.ModelId?.Trim();\n if (string.IsNullOrWhiteSpace(modelId))\n {\n modelId = provider.DefaultModelId;\n }\n\n if (string.IsNullOrWhiteSpace(modelId))\n {\n modelId = await _context.AiModelConfigs\n .Where(m => m.ProviderId == provider.Id && m.IsActive && !m.IsDeleted)\n .OrderByDescending(m => m.IsDefault)\n .ThenBy(m => m.SortOrder)\n .ThenBy(m => m.Name)\n .Select(m => m.ModelId)\n .FirstOrDefaultAsync(cancellationToken);\n }\n\n if (string.IsNullOrWhiteSpace(modelId))\n {\n throw new InvalidOperationException(\"Please select a model before checking connectivity.\");\n }\n\n var model = await _context.AiModelConfigs.FirstOrDefaultAsync(m =>\n m.ProviderId == provider.Id &&\n m.ModelId == modelId &&\n !m.IsDeleted,\n cancellationToken);\n\n var providerType = AiProviderResolver.ResolveEffectiveProviderType(\n string.IsNullOrWhiteSpace(request.ProviderType) ? provider.ProviderType : request.ProviderType,\n model?.ProviderType);\n var baseUrl = NormalizeConnectivityBaseUrl(\n string.IsNullOrWhiteSpace(request.BaseUrl) ? provider.BaseUrl : request.BaseUrl,\n providerType);\n var apiKey = request.ApiKey ?? provider.ApiKey;\n\n using var message = CreateConnectivityRequest(\n providerType,\n baseUrl,\n apiKey,\n modelId,\n model);\n var client = _httpClientFactory.CreateClient();\n using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);\n timeout.CancelAfter(TimeSpan.FromSeconds(30));\n var stopwatch = Stopwatch.StartNew();\n\n using var response = await client.SendAsync(message, timeout.Token);\n stopwatch.Stop();\n var content = await response.Content.ReadAsStringAsync(cancellationToken);\n\n if (response.IsSuccessStatusCode)\n {\n return new AiProviderConnectivityTestResult\n {\n Success = true,\n Message = $\"Connectivity check succeeded for model '{modelId}'.\",\n ProviderType = providerType,\n ModelId = modelId,\n StatusCode = (int)response.StatusCode,\n LatencyMs = stopwatch.ElapsedMilliseconds\n };\n }\n\n var error = string.IsNullOrWhiteSpace(content)\n ? response.ReasonPhrase\n : content.Trim();\n if (error is { Length: > 600 })\n {\n error = error[..600];\n }\n\n return new AiProviderConnectivityTestResult\n {\n Success = false,\n Message = $\"Connectivity check failed with HTTP {(int)response.StatusCode}: {error}\",\n ProviderType = providerType,\n ModelId = modelId,\n StatusCode = (int)response.StatusCode,\n LatencyMs = stopwatch.ElapsedMilliseconds\n };\n }\n\n public async Task<List<ModelConfigDto>> GetModelConfigsAsync()\n {\n var configs = await _context.ModelConfigs\n .Where(m => !m.IsDeleted)\n .OrderByDescending(m => m.IsDefault)\n .ThenBy(m => m.Name)\n .ToListAsync();\n\n var providerIds = configs\n .Where(m => !string.IsNullOrEmpty(m.AiProviderId))\n .Select(m => m.AiProviderId!)\n .Distinct()\n .ToList();\n\n var providers = providerIds.Count == 0\n ? new Dictionary<string, AiProviderConfig>()\n : await _context.AiProviderConfigs\n .Where(p => providerIds.Contains(p.Id) && !p.IsDeleted)\n .ToDictionaryAsync(p => p.Id);\n\n var aiModels = providerIds.Count == 0\n ? new Dictionary<string, AiModelConfig>(StringComparer.OrdinalIgnoreCase)\n : await _context.AiModelConfigs\n .Where(m => !m.IsDeleted && providerIds.Contains(m.ProviderId))\n .ToDictionaryAsync(\n m => CreateModelBindingKey(m.ProviderId, m.ModelId),\n StringComparer.OrdinalIgnoreCase);\n\n return configs.Select(m =>\n {\n providers.TryGetValue(m.AiProviderId ?? string.Empty, out var provider);\n aiModels.TryGetValue(\n CreateModelBindingKey(m.AiProviderId ?? string.Empty, m.ModelId),\n out var aiModel);\n var providerType = AiProviderResolver.ResolveEffectiveProviderType(\n provider?.ProviderType ?? m.Provider,\n aiModel?.ProviderType);\n return new ModelConfigDto\n {\n Id = m.Id,\n Name = m.Name,\n AiProviderId = m.AiProviderId,\n AiProviderName = provider?.DisplayName ?? provider?.Name,\n Provider = providerType,\n ModelId = m.ModelId,\n Endpoint = null,\n HasApiKey = provider != null && !string.IsNullOrEmpty(provider.ApiKey),\n IsDefault = m.IsDefault,\n IsActive = m.IsActive,\n Description = m.Description,\n CreatedAt = m.CreatedAt\n };\n }).ToList();\n }\n\n public async Task<ModelConfigDto> CreateModelConfigAsync(ModelConfigRequest request)\n {\n var provider = !string.IsNullOrWhiteSpace(request.AiProviderId)\n ? await _context.AiProviderConfigs.FirstOrDefaultAsync(p => p.Id == request.AiProviderId && !p.IsDeleted)\n : null;\n var providerType = provider == null\n ? AiProviderResolver.ResolveEffectiveProviderType(request.Provider, null)\n : await ResolveModelProviderTypeAsync(provider, request.ModelId);\n\n var config = new ModelConfig\n {\n Id = Guid.NewGuid().ToString(), Name = request.Name, Provider = providerType,\n AiProviderId = request.AiProviderId, ModelId = request.ModelId, Endpoint = null, ApiKey = null,\n IsDefault = request.IsDefault, IsActive = request.IsActive, Description = request.Description,\n CreatedAt = DateTime.UtcNow\n };\n _context.ModelConfigs.Add(config);\n await _context.SaveChangesAsync();\n return new ModelConfigDto\n {\n Id = config.Id, Name = config.Name, AiProviderId = config.AiProviderId,\n AiProviderName = provider?.DisplayName ?? provider?.Name, Provider = providerType,\n ModelId = config.ModelId, Endpoint = null, HasApiKey = provider != null && !string.IsNullOrEmpty(provider.ApiKey),\n IsDefault = config.IsDefault, IsActive = config.IsActive, Description = config.Description,\n CreatedAt = config.CreatedAt\n };\n }\n\n public async Task<bool> UpdateModelConfigAsync(string id, ModelConfigRequest request)\n {\n var config = await _context.ModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n var provider = !string.IsNullOrWhiteSpace(request.AiProviderId)\n ? await _context.AiProviderConfigs.FirstOrDefaultAsync(p => p.Id == request.AiProviderId && !p.IsDeleted)\n : null;\n var providerType = provider == null\n ? AiProviderResolver.ResolveEffectiveProviderType(request.Provider, null)\n : await ResolveModelProviderTypeAsync(provider, request.ModelId);\n config.Name = request.Name; config.Provider = providerType; config.ModelId = request.ModelId;\n config.AiProviderId = request.AiProviderId;\n config.Endpoint = null;\n config.ApiKey = null;\n config.IsDefault = request.IsDefault; config.IsActive = request.IsActive;\n config.Description = request.Description; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteModelConfigAsync(string id)\n {\n var config = await _context.ModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n private async Task<string> ResolveModelProviderTypeAsync(AiProviderConfig provider, string modelId)\n {\n var model = await _context.AiModelConfigs.FirstOrDefaultAsync(m =>\n m.ProviderId == provider.Id &&\n m.ModelId == modelId &&\n !m.IsDeleted);\n\n return AiProviderResolver.ResolveEffectiveProviderType(\n provider.ProviderType,\n model?.ProviderType);\n }\n\n private static HttpRequestMessage CreateConnectivityRequest(\n string providerType,\n string baseUrl,\n string? apiKey,\n string modelId,\n AiModelConfig? model)\n {\n var requestType = AiProviderResolver.ParseRequestType(providerType);\n return requestType switch\n {\n AiRequestType.OpenAIResponses => CreateJsonRequest(\n HttpMethod.Post,\n AppendEndpointPath(baseUrl, \"responses\", \"responses\"),\n apiKey,\n new\n {\n model = modelId,\n input = \"ping\",\n max_output_tokens = 1,\n stream = false\n }),\n AiRequestType.Anthropic => CreateJsonRequest(\n HttpMethod.Post,\n AppendEndpointPath(baseUrl, \"v1/messages\", \"messages\"),\n apiKey,\n new\n {\n model = modelId,\n max_tokens = 1,\n messages = new[]\n {\n new { role = \"user\", content = \"ping\" }\n }\n },\n configure: message =>\n {\n if (!string.IsNullOrWhiteSpace(apiKey))\n {\n message.Headers.Remove(\"Authorization\");\n message.Headers.Add(\"x-api-key\", apiKey);\n }\n\n message.Headers.Add(\"anthropic-version\", \"2023-06-01\");\n }),\n AiRequestType.DeepSeekOpenAI => CreateJsonRequest(\n HttpMethod.Post,\n AppendEndpointPath(baseUrl, \"chat/completions\", \"chat/completions\"),\n apiKey,\n CreateDeepSeekConnectivityBody(modelId, model)),\n AiRequestType.AzureOpenAI => CreateAzureOpenAIConnectivityRequest(baseUrl, apiKey, modelId),\n _ => CreateJsonRequest(\n HttpMethod.Post,\n AppendEndpointPath(baseUrl, \"chat/completions\", \"chat/completions\"),\n apiKey,\n new\n {\n model = modelId,\n max_tokens = 1,\n stream = false,\n messages = new[]\n {\n new { role = \"user\", content = \"ping\" }\n }\n })\n };\n }\n\n private static JsonObject CreateDeepSeekConnectivityBody(string modelId, AiModelConfig? model)\n {\n var body = new JsonObject\n {\n [\"model\"] = modelId,\n [\"max_tokens\"] = 1,\n [\"stream\"] = false,\n [\"messages\"] = new JsonArray\n {\n new JsonObject\n {\n [\"role\"] = \"user\",\n [\"content\"] = \"ping\"\n }\n }\n };\n\n if (model?.SupportsThinking == true &&\n !string.IsNullOrWhiteSpace(model.ThinkingConfigJson))\n {\n try\n {\n if (JsonNode.Parse(model.ThinkingConfigJson) is JsonObject config &&\n config[\"bodyParams\"] is JsonObject bodyParams)\n {\n foreach (var pair in bodyParams)\n {\n body[pair.Key] = pair.Value?.DeepClone();\n }\n }\n }\n catch (JsonException)\n {\n // Connectivity checks should still verify the base protocol if model metadata is malformed.\n }\n }\n\n return body;\n }\n\n private static HttpRequestMessage CreateAzureOpenAIConnectivityRequest(\n string baseUrl,\n string? apiKey,\n string modelId)\n {\n var endpoint = baseUrl.Contains(\"/chat/completions\", StringComparison.OrdinalIgnoreCase)\n ? baseUrl\n : $\"{baseUrl.TrimEnd('/')}/openai/deployments/{Uri.EscapeDataString(modelId)}/chat/completions?api-version=2024-10-21\";\n return CreateJsonRequest(\n HttpMethod.Post,\n endpoint,\n apiKey,\n new\n {\n max_tokens = 1,\n stream = false,\n messages = new[]\n {\n new { role = \"user\", content = \"ping\" }\n }\n },\n message =>\n {\n message.Headers.Remove(\"Authorization\");\n if (!string.IsNullOrWhiteSpace(apiKey))\n {\n message.Headers.Add(\"api-key\", apiKey);\n }\n });\n }\n\n private static HttpRequestMessage CreateJsonRequest(\n HttpMethod method,\n string endpoint,\n string? apiKey,\n object body,\n Action<HttpRequestMessage>? configure = null)\n {\n var message = new HttpRequestMessage(method, endpoint)\n {\n Content = new StringContent(\n JsonSerializer.Serialize(body),\n Encoding.UTF8,\n \"application/json\")\n };\n\n if (!string.IsNullOrWhiteSpace(apiKey))\n {\n message.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", apiKey);\n }\n\n configure?.Invoke(message);\n return message;\n }\n\n private static string NormalizeConnectivityBaseUrl(string? baseUrl, string providerType)\n {\n if (!string.IsNullOrWhiteSpace(baseUrl))\n {\n var normalized = baseUrl.Trim().TrimEnd('/');\n return AiProviderResolver.NormalizeProviderType(providerType).Equals(\"Anthropic\", StringComparison.OrdinalIgnoreCase) &&\n normalized.EndsWith(\"/v1\", StringComparison.OrdinalIgnoreCase)\n ? normalized[..^3]\n : normalized;\n }\n\n return AiProviderResolver.NormalizeProviderType(providerType) switch\n {\n \"Anthropic\" => \"https://api.anthropic.com\",\n \"DeepSeekOpenAI\" => \"https://api.deepseek.com/v1\",\n _ => \"https://api.openai.com/v1\"\n };\n }\n\n private static string AppendEndpointPath(string baseUrl, string path, string terminalPath)\n {\n var trimmed = baseUrl.TrimEnd('/');\n if (trimmed.EndsWith($\"/{terminalPath}\", StringComparison.OrdinalIgnoreCase))\n {\n return trimmed;\n }\n\n return $\"{trimmed}/{path.TrimStart('/')}\";\n }\n\n private static string CreateModelBindingKey(string providerId, string modelId)\n {\n return $\"{providerId}:{modelId}\";\n }\n\n private static AiProviderConfigDto MapAiProvider(AiProviderConfig provider)\n {\n return new AiProviderConfigDto\n {\n Id = provider.Id,\n Name = provider.Name,\n DisplayName = provider.DisplayName,\n ProviderType = provider.ProviderType,\n BaseUrl = provider.BaseUrl,\n HasApiKey = !string.IsNullOrEmpty(provider.ApiKey),\n AuthType = provider.AuthType,\n IsBuiltIn = provider.IsBuiltIn,\n IsActive = provider.IsActive,\n SupportsModelDiscovery = provider.SupportsModelDiscovery,\n ModelsEndpoint = provider.ModelsEndpoint,\n DefaultModelId = provider.DefaultModelId,\n SystemProxyUrl = provider.SystemProxyUrl,\n OAuthConfigJson = provider.OAuthConfigJson,\n ChannelConfigJson = provider.ChannelConfigJson,\n AccountsJson = provider.AccountsJson,\n RequestOverridesJson = provider.RequestOverridesJson,\n IconUrl = provider.IconUrl,\n Description = provider.Description,\n SortOrder = provider.SortOrder,\n CreatedAt = provider.CreatedAt\n };\n }\n\n private static AiModelConfigDto MapAiModel(AiModelConfig model, string? providerName)\n {\n return new AiModelConfigDto\n {\n Id = model.Id,\n ProviderId = model.ProviderId,\n ProviderName = providerName,\n ModelId = model.ModelId,\n Name = model.Name,\n DisplayName = model.DisplayName,\n ModelType = model.ModelType,\n ProviderType = model.ProviderType,\n ContextWindow = model.ContextWindow,\n MaxOutputTokens = model.MaxOutputTokens,\n InputTokenPrice = model.InputTokenPrice,\n OutputTokenPrice = model.OutputTokenPrice,\n CacheHitTokenPrice = model.CacheHitTokenPrice,\n CacheCreationTokenPrice = model.CacheCreationTokenPrice,\n SupportsThinking = model.SupportsThinking,\n SupportsVision = model.SupportsVision,\n SupportsTools = model.SupportsTools,\n SupportsJsonMode = model.SupportsJsonMode,\n IsDefault = model.IsDefault,\n IsActive = model.IsActive,\n CapabilitiesJson = model.CapabilitiesJson,\n ThinkingConfigJson = model.ThinkingConfigJson,\n RequestOverridesJson = model.RequestOverridesJson,\n TagsJson = model.TagsJson,\n Description = model.Description,\n SortOrder = model.SortOrder,\n CreatedAt = model.CreatedAt\n };\n }\n\n private static IReadOnlyList<DiscoveredAiModel> EnumerateModelElements(JsonElement root)\n {\n var source = root;\n if (root.ValueKind == JsonValueKind.Object &&\n root.TryGetProperty(\"data\", out var data) &&\n data.ValueKind == JsonValueKind.Array)\n {\n source = data;\n }\n\n if (source.ValueKind != JsonValueKind.Array)\n {\n return [];\n }\n\n var models = new List<DiscoveredAiModel>();\n var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);\n\n foreach (var item in source.EnumerateArray())\n {\n var model = ParseDiscoveredAiModel(item);\n if (model == null || !seen.Add(model.ModelId))\n {\n continue;\n }\n\n models.Add(model);\n }\n\n return models;\n }\n\n private static DiscoveredAiModel? ParseDiscoveredAiModel(JsonElement item)\n {\n if (item.ValueKind == JsonValueKind.String)\n {\n var stringModelId = item.GetString();\n return string.IsNullOrWhiteSpace(stringModelId)\n ? null\n : new DiscoveredAiModel(stringModelId, null, null, null, null, null, null);\n }\n\n if (item.ValueKind != JsonValueKind.Object)\n {\n return null;\n }\n\n var modelId = ReadOptionalString(item, \"id\");\n if (string.IsNullOrWhiteSpace(modelId))\n {\n return null;\n }\n\n return new DiscoveredAiModel(\n modelId,\n ReadOptionalString(item, \"name\"),\n ReadOptionalString(item, \"displayName\"),\n ReadOptionalDecimal(item, \"inputPrice\") ?? ReadOptionalDecimal(item, \"promptPrice\"),\n ReadOptionalDecimal(item, \"outputPrice\") ?? ReadOptionalDecimal(item, \"completionPrice\"),\n ReadOptionalDecimal(item, \"cacheHitPrice\"),\n ReadOptionalDecimal(item, \"cacheCreationPrice\"));\n }\n\n private static string? ReadOptionalString(JsonElement item, string propertyName)\n {\n return item.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String\n ? value.GetString()\n : null;\n }\n\n private static decimal? ReadOptionalDecimal(JsonElement item, string propertyName)\n {\n if (!item.TryGetProperty(propertyName, out var value))\n {\n return null;\n }\n\n return value.ValueKind switch\n {\n JsonValueKind.Number when value.TryGetDecimal(out var decimalValue) => decimalValue,\n JsonValueKind.String when decimal.TryParse(\n value.GetString(),\n NumberStyles.Number,\n CultureInfo.InvariantCulture,\n out var stringValue) => stringValue,\n _ => null\n };\n }\n\n private sealed record DiscoveredAiModel(\n string ModelId,\n string? Name,\n string? DisplayName,\n decimal? InputTokenPrice,\n decimal? OutputTokenPrice,\n decimal? CacheHitTokenPrice,\n decimal? CacheCreationTokenPrice);\n\n private static string? FindSkillMd(string directory)\n {\n var skillMd = Path.Combine(directory, \"SKILL.md\");\n if (File.Exists(skillMd)) return skillMd;\n foreach (var subDir in Directory.GetDirectories(directory))\n {\n skillMd = Path.Combine(subDir, \"SKILL.md\");\n if (File.Exists(skillMd)) return skillMd;\n }\n return null;\n }\n\n private static (Dictionary<string, object?> frontmatter, string body) ParseSkillMd(string content)\n {\n var frontmatter = new Dictionary<string, object?>();\n var body = content;\n if (content.StartsWith(\"---\"))\n {\n var endIndex = content.IndexOf(\"---\", 3);\n if (endIndex > 0)\n {\n var yamlContent = content[3..endIndex].Trim();\n body = content[(endIndex + 3)..].Trim();\n try\n {\n var deserializer = new DeserializerBuilder().WithNamingConvention(HyphenatedNamingConvention.Instance).Build();\n frontmatter = deserializer.Deserialize<Dictionary<string, object?>>(yamlContent) ?? new();\n }\n catch { }\n }\n }\n return (frontmatter, body);\n }\n\n private static List<SkillFileInfo> ListDirectoryFiles(string directory)\n {\n var files = new List<SkillFileInfo>();\n if (!Directory.Exists(directory)) return files;\n foreach (var file in Directory.GetFiles(directory, \"*\", SearchOption.AllDirectories))\n {\n var info = new FileInfo(file);\n files.Add(new SkillFileInfo { FileName = info.Name, RelativePath = Path.GetRelativePath(directory, file), Size = info.Length, LastModified = info.LastWriteTimeUtc });\n }\n return files;\n }\n\n private static long CalculateDirectorySize(string directory) =>\n !Directory.Exists(directory) ? 0 : Directory.GetFiles(directory, \"*\", SearchOption.AllDirectories).Sum(f => new FileInfo(f).Length);\n}\n"}, "24e31a340b661bee848fad546832d68b7bcfa3d3": {"t": 1785760376.2622972, "n": 47196, "h": "f982324b62722e59", "c": "using System.IO.Compression;\nusing System.Net.Http.Headers;\nusing System.Text;\nusing System.Text.Json;\nusing System.Text.Json.Nodes;\nusing System.Text.RegularExpressions;\nusing System.Diagnostics;\nusing Microsoft.EntityFrameworkCore;\nusing OpenDeepWiki.EFCore;\nusing OpenDeepWiki.Entities;\nusing OpenDeepWiki.Entities.Tools;\nusing OpenDeepWiki.Models.Admin;\nusing OpenDeepWiki.Services.AI;\nusing OpenDeepWiki.Agents;\nusing YamlDotNet.Serialization;\nusing YamlDotNet.Serialization.NamingConventions;\n\nnamespace OpenDeepWiki.Services.Admin;\n\npublic class AdminToolsService : IAdminToolsService\n{\n private readonly IContext _context;\n private readonly ILogger<AdminToolsService> _logger;\n private readonly IHttpClientFactory _httpClientFactory;\n private readonly string _skillsBasePath;\n\n public AdminToolsService(\n IContext context,\n ILogger<AdminToolsService> logger,\n IConfiguration configuration,\n IHttpClientFactory httpClientFactory)\n {\n _context = context;\n _logger = logger;\n _httpClientFactory = httpClientFactory;\n _skillsBasePath = configuration[\"Skills:BasePath\"] ?? Path.Combine(AppContext.BaseDirectory, \"skills\");\n if (!Directory.Exists(_skillsBasePath)) Directory.CreateDirectory(_skillsBasePath);\n }\n\n public async Task<List<McpConfigDto>> GetMcpConfigsAsync()\n {\n return await _context.McpConfigs.Where(m => !m.IsDeleted).OrderBy(m => m.SortOrder)\n .Select(m => new McpConfigDto\n {\n Id = m.Id, Name = m.Name, Description = m.Description, ServerUrl = m.ServerUrl,\n HasApiKey = !string.IsNullOrEmpty(m.ApiKey), IsActive = m.IsActive,\n SortOrder = m.SortOrder, CreatedAt = m.CreatedAt\n }).ToListAsync();\n }\n\n public async Task<McpConfigDto> CreateMcpConfigAsync(McpConfigRequest request)\n {\n var config = new McpConfig\n {\n Id = Guid.NewGuid().ToString(), Name = request.Name, Description = request.Description,\n ServerUrl = request.ServerUrl, ApiKey = request.ApiKey, IsActive = request.IsActive,\n SortOrder = request.SortOrder, CreatedAt = DateTime.UtcNow\n };\n _context.McpConfigs.Add(config);\n await _context.SaveChangesAsync();\n return new McpConfigDto\n {\n Id = config.Id, Name = config.Name, Description = config.Description,\n ServerUrl = config.ServerUrl, HasApiKey = !string.IsNullOrEmpty(config.ApiKey),\n IsActive = config.IsActive, SortOrder = config.SortOrder, CreatedAt = config.CreatedAt\n };\n }\n\n\n public async Task<bool> UpdateMcpConfigAsync(string id, McpConfigRequest request)\n {\n var config = await _context.McpConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.Name = request.Name; config.Description = request.Description;\n config.ServerUrl = request.ServerUrl;\n if (request.ApiKey != null) config.ApiKey = request.ApiKey;\n config.IsActive = request.IsActive; config.SortOrder = request.SortOrder;\n config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteMcpConfigAsync(string id)\n {\n var config = await _context.McpConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<List<SkillConfigDto>> GetSkillConfigsAsync()\n {\n var skills = await _context.SkillConfigs\n .Where(s => !s.IsDeleted)\n .OrderBy(s => s.SortOrder)\n .ThenBy(s => s.Name)\n .ToListAsync();\n\n var result = new List<SkillConfigDto>(skills.Count);\n foreach (var skill in skills)\n {\n var dto = MapSkillConfig(skill);\n dto.Frontmatter = await LoadSkillFrontmatterAsync(skill);\n result.Add(dto);\n }\n\n return result;\n }\n\n public async Task<SkillDetailDto?> GetSkillDetailAsync(string id)\n {\n var skill = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (skill == null) return null;\n var skillPath = Path.Combine(_skillsBasePath, skill.FolderPath);\n var detail = new SkillDetailDto(MapSkillConfig(skill));\n detail.Frontmatter = await LoadSkillFrontmatterAsync(skill);\n var skillMdPath = Path.Combine(skillPath, \"SKILL.md\");\n if (File.Exists(skillMdPath)) detail.SkillMdContent = await File.ReadAllTextAsync(skillMdPath);\n detail.Scripts = ListDirectoryFiles(Path.Combine(skillPath, \"scripts\"));\n detail.References = ListDirectoryFiles(Path.Combine(skillPath, \"references\"));\n detail.Assets = ListDirectoryFiles(Path.Combine(skillPath, \"assets\"));\n return detail;\n }\n\n private SkillConfigDto MapSkillConfig(SkillConfig skill)\n {\n return new SkillConfigDto\n {\n Id = skill.Id,\n Name = skill.Name,\n Description = skill.Description,\n License = skill.License,\n Compatibility = skill.Compatibility,\n AllowedTools = skill.AllowedTools,\n FolderPath = skill.FolderPath,\n IsActive = skill.IsActive,\n SortOrder = skill.SortOrder,\n Author = skill.Author,\n Version = skill.Version,\n Source = skill.Source.ToString().ToLower(),\n SourceUrl = skill.SourceUrl,\n HasScripts = skill.HasScripts,\n HasReferences = skill.HasReferences,\n HasAssets = skill.HasAssets,\n SkillMdSize = skill.SkillMdSize,\n TotalSize = skill.TotalSize,\n CreatedAt = skill.CreatedAt\n };\n }\n\n private async Task<Dictionary<string, object?>> LoadSkillFrontmatterAsync(SkillConfig skill)\n {\n try\n {\n var skillMdPath = Path.Combine(_skillsBasePath, skill.FolderPath, \"SKILL.md\");\n if (!File.Exists(skillMdPath))\n {\n return new Dictionary<string, object?>();\n }\n\n var content = await File.ReadAllTextAsync(skillMdPath);\n var (frontmatter, _) = ParseSkillMd(content);\n return frontmatter;\n }\n catch (Exception ex)\n {\n _logger.LogWarning(ex, \"Failed to load SKILL.md frontmatter for {Skill}\", skill.Name);\n return new Dictionary<string, object?>();\n }\n }\n\n\n public async Task<SkillConfigDto> UploadSkillAsync(Stream zipStream, string fileName)\n {\n var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());\n Directory.CreateDirectory(tempDir);\n try\n {\n using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Read))\n archive.ExtractToDirectory(tempDir);\n var skillMdPath = FindSkillMd(tempDir) ?? throw new InvalidOperationException(\"压缩包中未找到 SKILL.md\");\n var skillRootDir = Path.GetDirectoryName(skillMdPath)!;\n var (frontmatter, _) = ParseSkillMd(await File.ReadAllTextAsync(skillMdPath));\n if (!frontmatter.TryGetValue(\"name\", out var nameObj) || string.IsNullOrEmpty(nameObj?.ToString()))\n throw new InvalidOperationException(\"SKILL.md 缺少 name 字段\");\n var name = nameObj.ToString()!;\n if (!Regex.IsMatch(name, @\"^[a-z0-9]+(-[a-z0-9]+)*$\"))\n throw new InvalidOperationException(\"name 格式无效\");\n if (!frontmatter.TryGetValue(\"description\", out var descObj) || string.IsNullOrEmpty(descObj?.ToString()))\n throw new InvalidOperationException(\"SKILL.md 缺少 description 字段\");\n if (await _context.SkillConfigs.AnyAsync(s => s.Name == name && !s.IsDeleted))\n throw new InvalidOperationException($\"已存在同名 Skill: {name}\");\n var targetPath = Path.Combine(_skillsBasePath, name);\n if (Directory.Exists(targetPath)) Directory.Delete(targetPath, true);\n Directory.Move(skillRootDir, targetPath);\n var config = new SkillConfig\n {\n Id = Guid.NewGuid().ToString(), Name = name, Description = descObj.ToString()!,\n License = frontmatter.TryGetValue(\"license\", out var l) ? l?.ToString() : null,\n Compatibility = frontmatter.TryGetValue(\"compatibility\", out var c) ? c?.ToString() : null,\n AllowedTools = frontmatter.TryGetValue(\"allowed-tools\", out var t) ? t?.ToString() : null,\n FolderPath = name, IsActive = true, SortOrder = 0, Version = \"1.0.0\", Source = SkillSource.Local,\n HasScripts = Directory.Exists(Path.Combine(targetPath, \"scripts\")),\n HasReferences = Directory.Exists(Path.Combine(targetPath, \"references\")),\n HasAssets = Directory.Exists(Path.Combine(targetPath, \"assets\")),\n SkillMdSize = new FileInfo(Path.Combine(targetPath, \"SKILL.md\")).Length,\n TotalSize = CalculateDirectorySize(targetPath), CreatedAt = DateTime.UtcNow\n };\n if (frontmatter.TryGetValue(\"metadata\", out var meta) && meta is Dictionary<object, object> metaDict)\n {\n if (metaDict.TryGetValue(\"author\", out var a)) config.Author = a?.ToString();\n if (metaDict.TryGetValue(\"version\", out var v)) config.Version = v?.ToString() ?? \"1.0.0\";\n }\n _context.SkillConfigs.Add(config);\n await _context.SaveChangesAsync();\n _logger.LogInformation(\"上传 Skill: {Name}\", name);\n return new SkillConfigDto\n {\n Id = config.Id, Name = config.Name, Description = config.Description, License = config.License,\n Compatibility = config.Compatibility, AllowedTools = config.AllowedTools, FolderPath = config.FolderPath,\n IsActive = config.IsActive, SortOrder = config.SortOrder, Author = config.Author, Version = config.Version,\n Source = config.Source.ToString().ToLower(), HasScripts = config.HasScripts,\n HasReferences = config.HasReferences, HasAssets = config.HasAssets,\n SkillMdSize = config.SkillMdSize, TotalSize = config.TotalSize, CreatedAt = config.CreatedAt\n };\n }\n finally { if (Directory.Exists(tempDir)) try { Directory.Delete(tempDir, true); } catch { } }\n }\n\n public async Task<bool> UpdateSkillAsync(string id, SkillUpdateRequest request)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return false;\n if (request.IsActive.HasValue) config.IsActive = request.IsActive.Value;\n if (request.SortOrder.HasValue) config.SortOrder = request.SortOrder.Value;\n config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteSkillAsync(string id)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return false;\n var skillPath = Path.Combine(_skillsBasePath, config.FolderPath);\n if (Directory.Exists(skillPath)) Directory.Delete(skillPath, true);\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n _logger.LogInformation(\"删除 Skill: {Name}\", config.Name);\n return true;\n }\n\n public async Task<string?> GetSkillFileContentAsync(string id, string filePath)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return null;\n var normalizedPath = Path.GetFullPath(Path.Combine(_skillsBasePath, config.FolderPath, filePath));\n var skillBasePath = Path.GetFullPath(Path.Combine(_skillsBasePath, config.FolderPath));\n if (!normalizedPath.StartsWith(skillBasePath)) throw new UnauthorizedAccessException(\"非法路径\");\n return File.Exists(normalizedPath) ? await File.ReadAllTextAsync(normalizedPath) : null;\n }\n\n\n public async Task RefreshSkillsFromDiskAsync()\n {\n if (!Directory.Exists(_skillsBasePath)) return;\n var existingNames = (await _context.SkillConfigs.Where(s => !s.IsDeleted).ToListAsync()).Select(s => s.Name).ToHashSet();\n foreach (var dir in Directory.GetDirectories(_skillsBasePath))\n {\n var skillMdPath = Path.Combine(dir, \"SKILL.md\");\n if (!File.Exists(skillMdPath)) continue;\n var folderName = Path.GetFileName(dir);\n if (existingNames.Contains(folderName)) continue;\n try\n {\n var (frontmatter, _) = ParseSkillMd(await File.ReadAllTextAsync(skillMdPath));\n if (!frontmatter.TryGetValue(\"name\", out var nameObj)) continue;\n var name = nameObj?.ToString();\n if (string.IsNullOrEmpty(name) || name != folderName) continue;\n if (!frontmatter.TryGetValue(\"description\", out var descObj)) continue;\n var config = new SkillConfig\n {\n Id = Guid.NewGuid().ToString(), Name = name, Description = descObj?.ToString() ?? \"\",\n License = frontmatter.TryGetValue(\"license\", out var l) ? l?.ToString() : null,\n Compatibility = frontmatter.TryGetValue(\"compatibility\", out var c) ? c?.ToString() : null,\n AllowedTools = frontmatter.TryGetValue(\"allowed-tools\", out var t) ? t?.ToString() : null,\n FolderPath = folderName, IsActive = true,\n HasScripts = Directory.Exists(Path.Combine(dir, \"scripts\")),\n HasReferences = Directory.Exists(Path.Combine(dir, \"references\")),\n HasAssets = Directory.Exists(Path.Combine(dir, \"assets\")),\n SkillMdSize = new FileInfo(skillMdPath).Length,\n TotalSize = CalculateDirectorySize(dir), CreatedAt = DateTime.UtcNow\n };\n _context.SkillConfigs.Add(config);\n _logger.LogInformation(\"发现 Skill: {Name}\", name);\n }\n catch (Exception ex) { _logger.LogWarning(ex, \"解析失败: {Path}\", dir); }\n }\n await _context.SaveChangesAsync();\n }\n\n public async Task<List<AiProviderConfigDto>> GetAiProvidersAsync()\n {\n return await _context.AiProviderConfigs\n .Where(p => !p.IsDeleted)\n .OrderBy(p => p.SortOrder)\n .ThenBy(p => p.Name)\n .Select(p => new AiProviderConfigDto\n {\n Id = p.Id,\n Name = p.Name,\n DisplayName = p.DisplayName,\n ProviderType = p.ProviderType,\n BaseUrl = p.BaseUrl,\n HasApiKey = !string.IsNullOrEmpty(p.ApiKey),\n AuthType = p.AuthType,\n IsBuiltIn = p.IsBuiltIn,\n IsActive = p.IsActive,\n SupportsModelDiscovery = p.SupportsModelDiscovery,\n ModelsEndpoint = p.ModelsEndpoint,\n DefaultModelId = p.DefaultModelId,\n SystemProxyUrl = p.SystemProxyUrl,\n OAuthConfigJson = p.OAuthConfigJson,\n ChannelConfigJson = p.ChannelConfigJson,\n AccountsJson = p.AccountsJson,\n RequestOverridesJson = p.RequestOverridesJson,\n IconUrl = p.IconUrl,\n Description = p.Description,\n SortOrder = p.SortOrder,\n CreatedAt = p.CreatedAt\n })\n .ToListAsync();\n }\n\n public async Task<AiProviderConfigDto> CreateAiProviderAsync(AiProviderConfigRequest request)\n {\n var provider = new AiProviderConfig\n {\n Id = Guid.NewGuid().ToString(),\n Name = request.Name,\n DisplayName = request.DisplayName,\n ProviderType = request.ProviderType,\n BaseUrl = request.BaseUrl.TrimEnd('/'),\n ApiKey = request.ApiKey,\n AuthType = request.AuthType,\n IsBuiltIn = request.IsBuiltIn,\n IsActive = request.IsActive,\n SupportsModelDiscovery = request.SupportsModelDiscovery,\n ModelsEndpoint = request.ModelsEndpoint,\n DefaultModelId = request.DefaultModelId,\n SystemProxyUrl = request.SystemProxyUrl,\n OAuthConfigJson = request.OAuthConfigJson,\n ChannelConfigJson = request.ChannelConfigJson,\n AccountsJson = request.AccountsJson,\n RequestOverridesJson = request.RequestOverridesJson,\n IconUrl = request.IconUrl,\n Description = request.Description,\n SortOrder = request.SortOrder,\n CreatedAt = DateTime.UtcNow\n };\n\n _context.AiProviderConfigs.Add(provider);\n await _context.SaveChangesAsync();\n return MapAiProvider(provider);\n }\n\n public async Task<bool> UpdateAiProviderAsync(string id, AiProviderConfigRequest request)\n {\n var provider = await _context.AiProviderConfigs.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted);\n if (provider == null) return false;\n\n provider.Name = request.Name;\n provider.DisplayName = request.DisplayName;\n provider.ProviderType = request.ProviderType;\n provider.BaseUrl = request.BaseUrl.TrimEnd('/');\n if (request.ApiKey != null) provider.ApiKey = request.ApiKey;\n provider.AuthType = request.AuthType;\n provider.IsBuiltIn = request.IsBuiltIn;\n provider.IsActive = request.IsActive;\n provider.SupportsModelDiscovery = request.SupportsModelDiscovery;\n provider.ModelsEndpoint = request.ModelsEndpoint;\n provider.DefaultModelId = request.DefaultModelId;\n provider.SystemProxyUrl = request.SystemProxyUrl;\n provider.OAuthConfigJson = request.OAuthConfigJson;\n provider.ChannelConfigJson = request.ChannelConfigJson;\n provider.AccountsJson = request.AccountsJson;\n provider.RequestOverridesJson = request.RequestOverridesJson;\n provider.IconUrl = request.IconUrl;\n provider.Description = request.Description;\n provider.SortOrder = request.SortOrder;\n provider.UpdatedAt = DateTime.UtcNow;\n\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteAiProviderAsync(string id)\n {\n var provider = await _context.AiProviderConfigs.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted);\n if (provider == null) return false;\n provider.IsDeleted = true;\n provider.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<List<AiModelConfigDto>> GetAiModelsAsync(string? providerId = null)\n {\n var query = _context.AiModelConfigs.Where(m => !m.IsDeleted);\n if (!string.IsNullOrWhiteSpace(providerId))\n {\n query = query.Where(m => m.ProviderId == providerId);\n }\n\n var models = await query\n .OrderByDescending(m => m.IsDefault)\n .ThenBy(m => m.SortOrder)\n .ThenBy(m => m.Name)\n .ToListAsync();\n\n var providerIds = models.Select(m => m.ProviderId).Distinct().ToList();\n var providers = providerIds.Count == 0\n ? new Dictionary<string, string>()\n : await _context.AiProviderConfigs\n .Where(p => providerIds.Contains(p.Id) && !p.IsDeleted)\n .ToDictionaryAsync(p => p.Id, p => p.DisplayName ?? p.Name);\n\n return models.Select(m => MapAiModel(m, providers.GetValueOrDefault(m.ProviderId))).ToList();\n }\n\n public async Task<AiModelConfigDto> CreateAiModelAsync(AiModelConfigRequest request)\n {\n var model = new AiModelConfig\n {\n Id = Guid.NewGuid().ToString(),\n ProviderId = request.ProviderId,\n ModelId = request.ModelId,\n Name = string.IsNullOrWhiteSpace(request.Name) ? request.ModelId : request.Name,\n DisplayName = request.DisplayName,\n ModelType = request.ModelType,\n ProviderType = AiProviderResolver.NormalizeModelProviderType(request.ProviderType, request.ModelId),\n ContextWindow = request.ContextWindow,\n MaxOutputTokens = request.MaxOutputTokens,\n InputTokenPrice = request.InputTokenPrice,\n OutputTokenPrice = request.OutputTokenPrice,\n SupportsThinking = request.SupportsThinking,\n SupportsVision = request.SupportsVision,\n SupportsTools = request.SupportsTools,\n SupportsJsonMode = request.SupportsJsonMode,\n IsDefault = request.IsDefault,\n IsActive = request.IsActive,\n CapabilitiesJson = request.CapabilitiesJson,\n ThinkingConfigJson = request.ThinkingConfigJson,\n RequestOverridesJson = request.RequestOverridesJson,\n TagsJson = request.TagsJson,\n Description = request.Description,\n SortOrder = request.SortOrder,\n CreatedAt = DateTime.UtcNow\n };\n\n _context.AiModelConfigs.Add(model);\n await _context.SaveChangesAsync();\n return MapAiModel(model, null);\n }\n\n public async Task<bool> UpdateAiModelAsync(string id, AiModelConfigRequest request)\n {\n var model = await _context.AiModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (model == null) return false;\n\n model.ProviderId = request.ProviderId;\n model.ModelId = request.ModelId;\n model.Name = string.IsNullOrWhiteSpace(request.Name) ? request.ModelId : request.Name;\n model.DisplayName = request.DisplayName;\n model.ModelType = request.ModelType;\n model.ProviderType = AiProviderResolver.NormalizeModelProviderType(request.ProviderType, request.ModelId);\n model.ContextWindow = request.ContextWindow;\n model.MaxOutputTokens = request.MaxOutputTokens;\n model.InputTokenPrice = request.InputTokenPrice;\n model.OutputTokenPrice = request.OutputTokenPrice;\n model.SupportsThinking = request.SupportsThinking;\n model.SupportsVision = request.SupportsVision;\n model.SupportsTools = request.SupportsTools;\n model.SupportsJsonMode = request.SupportsJsonMode;\n model.IsDefault = request.IsDefault;\n model.IsActive = request.IsActive;\n model.CapabilitiesJson = request.CapabilitiesJson;\n model.ThinkingConfigJson = request.ThinkingConfigJson;\n model.RequestOverridesJson = request.RequestOverridesJson;\n model.TagsJson = request.TagsJson;\n model.Description = request.Description;\n model.SortOrder = request.SortOrder;\n model.UpdatedAt = DateTime.UtcNow;\n\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteAiModelAsync(string id)\n {\n var model = await _context.AiModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (model == null) return false;\n model.IsDeleted = true;\n model.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<List<AiModelConfigDto>> DiscoverAiModelsAsync(\n string providerId,\n CancellationToken cancellationToken = default)\n {\n var provider = await _context.AiProviderConfigs\n .FirstOrDefaultAsync(p => p.Id == providerId && !p.IsDeleted, cancellationToken);\n if (provider == null)\n {\n throw new InvalidOperationException(\"AI provider does not exist.\");\n }\n\n var endpoint = string.IsNullOrWhiteSpace(provider.ModelsEndpoint)\n ? $\"{provider.BaseUrl.TrimEnd('/')}/models\"\n : provider.ModelsEndpoint;\n\n var client = _httpClientFactory.CreateClient();\n using var request = new HttpRequestMessage(HttpMethod.Get, endpoint);\n if (!string.IsNullOrWhiteSpace(provider.ApiKey))\n {\n if (provider.ProviderType.Equals(\"Anthropic\", StringComparison.OrdinalIgnoreCase))\n {\n request.Headers.Add(\"x-api-key\", provider.ApiKey);\n request.Headers.Add(\"anthropic-version\", \"2023-06-01\");\n }\n else\n {\n request.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", provider.ApiKey);\n }\n }\n\n using var response = await client.SendAsync(request, cancellationToken);\n response.EnsureSuccessStatusCode();\n var json = await response.Content.ReadAsStringAsync(cancellationToken);\n\n using var document = JsonDocument.Parse(json);\n var modelElements = EnumerateModelElements(document.RootElement);\n return modelElements\n .Select(id => new AiModelConfigDto\n {\n ProviderId = provider.Id,\n ProviderName = provider.DisplayName ?? provider.Name,\n ModelId = id,\n Name = id,\n DisplayName = id,\n ModelType = \"chat\",\n ProviderType = AiProviderResolver.NormalizeModelProviderType(null, id) ?? provider.ProviderType,\n SupportsTools = true,\n IsActive = true\n })\n .ToList();\n }\n\n public async Task<AiProviderConnectivityTestResult> TestAiProviderConnectivityAsync(\n string providerId,\n AiProviderConnectivityTestRequest request,\n CancellationToken cancellationToken = default)\n {\n var provider = await _context.AiProviderConfigs\n .FirstOrDefaultAsync(p => p.Id == providerId && !p.IsDeleted, cancellationToken);\n if (provider == null)\n {\n throw new InvalidOperationException(\"AI provider does not exist.\");\n }\n\n request ??= new AiProviderConnectivityTestRequest();\n var modelId = request.ModelId?.Trim();\n if (string.IsNullOrWhiteSpace(modelId))\n {\n modelId = provider.DefaultModelId;\n }\n\n if (string.IsNullOrWhiteSpace(modelId))\n {\n modelId = await _context.AiModelConfigs\n .Where(m => m.ProviderId == provider.Id && m.IsActive && !m.IsDeleted)\n .OrderByDescending(m => m.IsDefault)\n .ThenBy(m => m.SortOrder)\n .ThenBy(m => m.Name)\n .Select(m => m.ModelId)\n .FirstOrDefaultAsync(cancellationToken);\n }\n\n if (string.IsNullOrWhiteSpace(modelId))\n {\n throw new InvalidOperationException(\"Please select a model before checking connectivity.\");\n }\n\n var model = await _context.AiModelConfigs.FirstOrDefaultAsync(m =>\n m.ProviderId == provider.Id &&\n m.ModelId == modelId &&\n !m.IsDeleted,\n cancellationToken);\n\n var providerType = AiProviderResolver.ResolveEffectiveProviderType(\n string.IsNullOrWhiteSpace(request.ProviderType) ? provider.ProviderType : request.ProviderType,\n model?.ProviderType);\n var baseUrl = NormalizeConnectivityBaseUrl(\n string.IsNullOrWhiteSpace(request.BaseUrl) ? provider.BaseUrl : request.BaseUrl,\n providerType);\n var apiKey = request.ApiKey ?? provider.ApiKey;\n\n using var message = CreateConnectivityRequest(\n providerType,\n baseUrl,\n apiKey,\n modelId,\n model);\n var client = _httpClientFactory.CreateClient();\n using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);\n timeout.CancelAfter(TimeSpan.FromSeconds(30));\n var stopwatch = Stopwatch.StartNew();\n\n using var response = await client.SendAsync(message, timeout.Token);\n stopwatch.Stop();\n var content = await response.Content.ReadAsStringAsync(cancellationToken);\n\n if (response.IsSuccessStatusCode)\n {\n return new AiProviderConnectivityTestResult\n {\n Success = true,\n Message = $\"Connectivity check succeeded for model '{modelId}'.\",\n ProviderType = providerType,\n ModelId = modelId,\n StatusCode = (int)response.StatusCode,\n LatencyMs = stopwatch.ElapsedMilliseconds\n };\n }\n\n var error = string.IsNullOrWhiteSpace(content)\n ? response.ReasonPhrase\n : content.Trim();\n if (error is { Length: > 600 })\n {\n error = error[..600];\n }\n\n return new AiProviderConnectivityTestResult\n {\n Success = false,\n Message = $\"Connectivity check failed with HTTP {(int)response.StatusCode}: {error}\",\n ProviderType = providerType,\n ModelId = modelId,\n StatusCode = (int)response.StatusCode,\n LatencyMs = stopwatch.ElapsedMilliseconds\n };\n }\n\n public async Task<List<ModelConfigDto>> GetModelConfigsAsync()\n {\n var configs = await _context.ModelConfigs\n .Where(m => !m.IsDeleted)\n .OrderByDescending(m => m.IsDefault)\n .ThenBy(m => m.Name)\n .ToListAsync();\n\n var providerIds = configs\n .Where(m => !string.IsNullOrEmpty(m.AiProviderId))\n .Select(m => m.AiProviderId!)\n .Distinct()\n .ToList();\n\n var providers = providerIds.Count == 0\n ? new Dictionary<string, AiProviderConfig>()\n : await _context.AiProviderConfigs\n .Where(p => providerIds.Contains(p.Id) && !p.IsDeleted)\n .ToDictionaryAsync(p => p.Id);\n\n var aiModels = providerIds.Count == 0\n ? new Dictionary<string, AiModelConfig>(StringComparer.OrdinalIgnoreCase)\n : await _context.AiModelConfigs\n .Where(m => !m.IsDeleted && providerIds.Contains(m.ProviderId))\n .ToDictionaryAsync(\n m => CreateModelBindingKey(m.ProviderId, m.ModelId),\n StringComparer.OrdinalIgnoreCase);\n\n return configs.Select(m =>\n {\n providers.TryGetValue(m.AiProviderId ?? string.Empty, out var provider);\n aiModels.TryGetValue(\n CreateModelBindingKey(m.AiProviderId ?? string.Empty, m.ModelId),\n out var aiModel);\n var providerType = AiProviderResolver.ResolveEffectiveProviderType(\n provider?.ProviderType ?? m.Provider,\n aiModel?.ProviderType);\n return new ModelConfigDto\n {\n Id = m.Id,\n Name = m.Name,\n AiProviderId = m.AiProviderId,\n AiProviderName = provider?.DisplayName ?? provider?.Name,\n Provider = providerType,\n ModelId = m.ModelId,\n Endpoint = null,\n HasApiKey = provider != null && !string.IsNullOrEmpty(provider.ApiKey),\n IsDefault = m.IsDefault,\n IsActive = m.IsActive,\n Description = m.Description,\n CreatedAt = m.CreatedAt\n };\n }).ToList();\n }\n\n public async Task<ModelConfigDto> CreateModelConfigAsync(ModelConfigRequest request)\n {\n var provider = !string.IsNullOrWhiteSpace(request.AiProviderId)\n ? await _context.AiProviderConfigs.FirstOrDefaultAsync(p => p.Id == request.AiProviderId && !p.IsDeleted)\n : null;\n var providerType = provider == null\n ? AiProviderResolver.ResolveEffectiveProviderType(request.Provider, null)\n : await ResolveModelProviderTypeAsync(provider, request.ModelId);\n\n var config = new ModelConfig\n {\n Id = Guid.NewGuid().ToString(), Name = request.Name, Provider = providerType,\n AiProviderId = request.AiProviderId, ModelId = request.ModelId, Endpoint = null, ApiKey = null,\n IsDefault = request.IsDefault, IsActive = request.IsActive, Description = request.Description,\n CreatedAt = DateTime.UtcNow\n };\n _context.ModelConfigs.Add(config);\n await _context.SaveChangesAsync();\n return new ModelConfigDto\n {\n Id = config.Id, Name = config.Name, AiProviderId = config.AiProviderId,\n AiProviderName = provider?.DisplayName ?? provider?.Name, Provider = providerType,\n ModelId = config.ModelId, Endpoint = null, HasApiKey = provider != null && !string.IsNullOrEmpty(provider.ApiKey),\n IsDefault = config.IsDefault, IsActive = config.IsActive, Description = config.Description,\n CreatedAt = config.CreatedAt\n };\n }\n\n public async Task<bool> UpdateModelConfigAsync(string id, ModelConfigRequest request)\n {\n var config = await _context.ModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n var provider = !string.IsNullOrWhiteSpace(request.AiProviderId)\n ? await _context.AiProviderConfigs.FirstOrDefaultAsync(p => p.Id == request.AiProviderId && !p.IsDeleted)\n : null;\n var providerType = provider == null\n ? AiProviderResolver.ResolveEffectiveProviderType(request.Provider, null)\n : await ResolveModelProviderTypeAsync(provider, request.ModelId);\n config.Name = request.Name; config.Provider = providerType; config.ModelId = request.ModelId;\n config.AiProviderId = request.AiProviderId;\n config.Endpoint = null;\n config.ApiKey = null;\n config.IsDefault = request.IsDefault; config.IsActive = request.IsActive;\n config.Description = request.Description; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteModelConfigAsync(string id)\n {\n var config = await _context.ModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n private async Task<string> ResolveModelProviderTypeAsync(AiProviderConfig provider, string modelId)\n {\n var model = await _context.AiModelConfigs.FirstOrDefaultAsync(m =>\n m.ProviderId == provider.Id &&\n m.ModelId == modelId &&\n !m.IsDeleted);\n\n return AiProviderResolver.ResolveEffectiveProviderType(\n provider.ProviderType,\n model?.ProviderType);\n }\n\n private static HttpRequestMessage CreateConnectivityRequest(\n string providerType,\n string baseUrl,\n string? apiKey,\n string modelId,\n AiModelConfig? model)\n {\n var requestType = AiProviderResolver.ParseRequestType(providerType);\n return requestType switch\n {\n AiRequestType.OpenAIResponses => CreateJsonRequest(\n HttpMethod.Post,\n AppendEndpointPath(baseUrl, \"responses\", \"responses\"),\n apiKey,\n new\n {\n model = modelId,\n input = \"ping\",\n max_output_tokens = 1,\n stream = false\n }),\n AiRequestType.Anthropic => CreateJsonRequest(\n HttpMethod.Post,\n AppendEndpointPath(baseUrl, \"v1/messages\", \"messages\"),\n apiKey,\n new\n {\n model = modelId,\n max_tokens = 1,\n messages = new[]\n {\n new { role = \"user\", content = \"ping\" }\n }\n },\n configure: message =>\n {\n if (!string.IsNullOrWhiteSpace(apiKey))\n {\n message.Headers.Remove(\"Authorization\");\n message.Headers.Add(\"x-api-key\", apiKey);\n }\n\n message.Headers.Add(\"anthropic-version\", \"2023-06-01\");\n }),\n AiRequestType.DeepSeekOpenAI => CreateJsonRequest(\n HttpMethod.Post,\n AppendEndpointPath(baseUrl, \"chat/completions\", \"chat/completions\"),\n apiKey,\n CreateDeepSeekConnectivityBody(modelId, model)),\n AiRequestType.AzureOpenAI => CreateAzureOpenAIConnectivityRequest(baseUrl, apiKey, modelId),\n _ => CreateJsonRequest(\n HttpMethod.Post,\n AppendEndpointPath(baseUrl, \"chat/completions\", \"chat/completions\"),\n apiKey,\n new\n {\n model = modelId,\n max_tokens = 1,\n stream = false,\n messages = new[]\n {\n new { role = \"user\", content = \"ping\" }\n }\n })\n };\n }\n\n private static JsonObject CreateDeepSeekConnectivityBody(string modelId, AiModelConfig? model)\n {\n var body = new JsonObject\n {\n [\"model\"] = modelId,\n [\"max_tokens\"] = 1,\n [\"stream\"] = false,\n [\"messages\"] = new JsonArray\n {\n new JsonObject\n {\n [\"role\"] = \"user\",\n [\"content\"] = \"ping\"\n }\n }\n };\n\n if (model?.SupportsThinking == true &&\n !string.IsNullOrWhiteSpace(model.ThinkingConfigJson))\n {\n try\n {\n if (JsonNode.Parse(model.ThinkingConfigJson) is JsonObject config &&\n config[\"bodyParams\"] is JsonObject bodyParams)\n {\n foreach (var pair in bodyParams)\n {\n body[pair.Key] = pair.Value?.DeepClone();\n }\n }\n }\n catch (JsonException)\n {\n // Connectivity checks should still verify the base protocol if model metadata is malformed.\n }\n }\n\n return body;\n }\n\n private static HttpRequestMessage CreateAzureOpenAIConnectivityRequest(\n string baseUrl,\n string? apiKey,\n string modelId)\n {\n var endpoint = baseUrl.Contains(\"/chat/completions\", StringComparison.OrdinalIgnoreCase)\n ? baseUrl\n : $\"{baseUrl.TrimEnd('/')}/openai/deployments/{Uri.EscapeDataString(modelId)}/chat/completions?api-version=2024-10-21\";\n return CreateJsonRequest(\n HttpMethod.Post,\n endpoint,\n apiKey,\n new\n {\n max_tokens = 1,\n stream = false,\n messages = new[]\n {\n new { role = \"user\", content = \"ping\" }\n }\n },\n message =>\n {\n message.Headers.Remove(\"Authorization\");\n if (!string.IsNullOrWhiteSpace(apiKey))\n {\n message.Headers.Add(\"api-key\", apiKey);\n }\n });\n }\n\n private static HttpRequestMessage CreateJsonRequest(\n HttpMethod method,\n string endpoint,\n string? apiKey,\n object body,\n Action<HttpRequestMessage>? configure = null)\n {\n var message = new HttpRequestMessage(method, endpoint)\n {\n Content = new StringContent(\n JsonSerializer.Serialize(body),\n Encoding.UTF8,\n \"application/json\")\n };\n\n if (!string.IsNullOrWhiteSpace(apiKey))\n {\n message.Headers.Authorization = new AuthenticationHeaderValue(\"Bearer\", apiKey);\n }\n\n configure?.Invoke(message);\n return message;\n }\n\n private static string NormalizeConnectivityBaseUrl(string? baseUrl, string providerType)\n {\n if (!string.IsNullOrWhiteSpace(baseUrl))\n {\n var normalized = baseUrl.Trim().TrimEnd('/');\n return AiProviderResolver.NormalizeProviderType(providerType).Equals(\"Anthropic\", StringComparison.OrdinalIgnoreCase) &&\n normalized.EndsWith(\"/v1\", StringComparison.OrdinalIgnoreCase)\n ? normalized[..^3]\n : normalized;\n }\n\n return AiProviderResolver.NormalizeProviderType(providerType) switch\n {\n \"Anthropic\" => \"https://api.anthropic.com\",\n \"DeepSeekOpenAI\" => \"https://api.deepseek.com/v1\",\n _ => \"https://api.openai.com/v1\"\n };\n }\n\n private static string AppendEndpointPath(string baseUrl, string path, string terminalPath)\n {\n var trimmed = baseUrl.TrimEnd('/');\n if (trimmed.EndsWith($\"/{terminalPath}\", StringComparison.OrdinalIgnoreCase))\n {\n return trimmed;\n }\n\n return $\"{trimmed}/{path.TrimStart('/')}\";\n }\n\n private static string CreateModelBindingKey(string providerId, string modelId)\n {\n return $\"{providerId}:{modelId}\";\n }\n\n private static AiProviderConfigDto MapAiProvider(AiProviderConfig provider)\n {\n return new AiProviderConfigDto\n {\n Id = provider.Id,\n Name = provider.Name,\n DisplayName = provider.DisplayName,\n ProviderType = provider.ProviderType,\n BaseUrl = provider.BaseUrl,\n HasApiKey = !string.IsNullOrEmpty(provider.ApiKey),\n AuthType = provider.AuthType,\n IsBuiltIn = provider.IsBuiltIn,\n IsActive = provider.IsActive,\n SupportsModelDiscovery = provider.SupportsModelDiscovery,\n ModelsEndpoint = provider.ModelsEndpoint,\n DefaultModelId = provider.DefaultModelId,\n SystemProxyUrl = provider.SystemProxyUrl,\n OAuthConfigJson = provider.OAuthConfigJson,\n ChannelConfigJson = provider.ChannelConfigJson,\n AccountsJson = provider.AccountsJson,\n RequestOverridesJson = provider.RequestOverridesJson,\n IconUrl = provider.IconUrl,\n Description = provider.Description,\n SortOrder = provider.SortOrder,\n CreatedAt = provider.CreatedAt\n };\n }\n\n private static AiModelConfigDto MapAiModel(AiModelConfig model, string? providerName)\n {\n return new AiModelConfigDto\n {\n Id = model.Id,\n ProviderId = model.ProviderId,\n ProviderName = providerName,\n ModelId = model.ModelId,\n Name = model.Name,\n DisplayName = model.DisplayName,\n ModelType = model.ModelType,\n ProviderType = model.ProviderType,\n ContextWindow = model.ContextWindow,\n MaxOutputTokens = model.MaxOutputTokens,\n InputTokenPrice = model.InputTokenPrice,\n OutputTokenPrice = model.OutputTokenPrice,\n SupportsThinking = model.SupportsThinking,\n SupportsVision = model.SupportsVision,\n SupportsTools = model.SupportsTools,\n SupportsJsonMode = model.SupportsJsonMode,\n IsDefault = model.IsDefault,\n IsActive = model.IsActive,\n CapabilitiesJson = model.CapabilitiesJson,\n ThinkingConfigJson = model.ThinkingConfigJson,\n RequestOverridesJson = model.RequestOverridesJson,\n TagsJson = model.TagsJson,\n Description = model.Description,\n SortOrder = model.SortOrder,\n CreatedAt = model.CreatedAt\n };\n }\n\n private static IEnumerable<string> EnumerateModelElements(JsonElement root)\n {\n var source = root;\n if (root.ValueKind == JsonValueKind.Object &&\n root.TryGetProperty(\"data\", out var data) &&\n data.ValueKind == JsonValueKind.Array)\n {\n source = data;\n }\n\n if (source.ValueKind != JsonValueKind.Array)\n {\n return [];\n }\n\n return source.EnumerateArray()\n .Select(item =>\n {\n if (item.ValueKind == JsonValueKind.String)\n {\n return item.GetString();\n }\n\n if (item.ValueKind == JsonValueKind.Object &&\n item.TryGetProperty(\"id\", out var id) &&\n id.ValueKind == JsonValueKind.String)\n {\n return id.GetString();\n }\n\n return null;\n })\n .Where(id => !string.IsNullOrWhiteSpace(id))\n .Select(id => id!)\n .Distinct(StringComparer.OrdinalIgnoreCase);\n }\n\n private static string? FindSkillMd(string directory)\n {\n var skillMd = Path.Combine(directory, \"SKILL.md\");\n if (File.Exists(skillMd)) return skillMd;\n foreach (var subDir in Directory.GetDirectories(directory))\n {\n skillMd = Path.Combine(subDir, \"SKILL.md\");\n if (File.Exists(skillMd)) return skillMd;\n }\n return null;\n }\n\n private static (Dictionary<string, object?> frontmatter, string body) ParseSkillMd(string content)\n {\n var frontmatter = new Dictionary<string, object?>();\n var body = content;\n if (content.StartsWith(\"---\"))\n {\n var endIndex = content.IndexOf(\"---\", 3);\n if (endIndex > 0)\n {\n var yamlContent = content[3..endIndex].Trim();\n body = content[(endIndex + 3)..].Trim();\n try\n {\n var deserializer = new DeserializerBuilder().WithNamingConvention(HyphenatedNamingConvention.Instance).Build();\n frontmatter = deserializer.Deserialize<Dictionary<string, object?>>(yamlContent) ?? new();\n }\n catch { }\n }\n }\n return (frontmatter, body);\n }\n\n private static List<SkillFileInfo> ListDirectoryFiles(string directory)\n {\n var files = new List<SkillFileInfo>();\n if (!Directory.Exists(directory)) return files;\n foreach (var file in Directory.GetFiles(directory, \"*\", SearchOption.AllDirectories))\n {\n var info = new FileInfo(file);\n files.Add(new SkillFileInfo { FileName = info.Name, RelativePath = Path.GetRelativePath(directory, file), Size = info.Length, LastModified = info.LastWriteTimeUtc });\n }\n return files;\n }\n\n private static long CalculateDirectorySize(string directory) =>\n !Directory.Exists(directory) ? 0 : Directory.GetFiles(directory, \"*\", SearchOption.AllDirectories).Sum(f => new FileInfo(f).Length);\n}\n"}, "fd912a9226f6232662cbc3869dd5463061972c6e": {"t": 1785760377.6029134, "n": 18803, "h": "870838350e5cf6f5", "c": "using System.IO.Compression;\nusing System.Text.RegularExpressions;\nusing Microsoft.EntityFrameworkCore;\nusing OpenDeepWiki.EFCore;\nusing OpenDeepWiki.Entities;\nusing OpenDeepWiki.Entities.Tools;\nusing OpenDeepWiki.Models.Admin;\nusing YamlDotNet.Serialization;\nusing YamlDotNet.Serialization.NamingConventions;\n\nnamespace OpenDeepWiki.Services.Admin;\n\npublic class AdminToolsService : IAdminToolsService\n{\n private readonly IContext _context;\n private readonly ILogger<AdminToolsService> _logger;\n private readonly string _skillsBasePath;\n\n public AdminToolsService(IContext context, ILogger<AdminToolsService> logger, IConfiguration configuration)\n {\n _context = context;\n _logger = logger;\n _skillsBasePath = configuration[\"Skills:BasePath\"] ?? Path.Combine(AppContext.BaseDirectory, \"skills\");\n if (!Directory.Exists(_skillsBasePath)) Directory.CreateDirectory(_skillsBasePath);\n }\n\n public async Task<List<McpConfigDto>> GetMcpConfigsAsync()\n {\n return await _context.McpConfigs.Where(m => !m.IsDeleted).OrderBy(m => m.SortOrder)\n .Select(m => new McpConfigDto\n {\n Id = m.Id, Name = m.Name, Description = m.Description, ServerUrl = m.ServerUrl,\n HasApiKey = !string.IsNullOrEmpty(m.ApiKey), IsActive = m.IsActive,\n SortOrder = m.SortOrder, CreatedAt = m.CreatedAt\n }).ToListAsync();\n }\n\n public async Task<McpConfigDto> CreateMcpConfigAsync(McpConfigRequest request)\n {\n var config = new McpConfig\n {\n Id = Guid.NewGuid().ToString(), Name = request.Name, Description = request.Description,\n ServerUrl = request.ServerUrl, ApiKey = request.ApiKey, IsActive = request.IsActive,\n SortOrder = request.SortOrder, CreatedAt = DateTime.UtcNow\n };\n _context.McpConfigs.Add(config);\n await _context.SaveChangesAsync();\n return new McpConfigDto\n {\n Id = config.Id, Name = config.Name, Description = config.Description,\n ServerUrl = config.ServerUrl, HasApiKey = !string.IsNullOrEmpty(config.ApiKey),\n IsActive = config.IsActive, SortOrder = config.SortOrder, CreatedAt = config.CreatedAt\n };\n }\n\n\n public async Task<bool> UpdateMcpConfigAsync(string id, McpConfigRequest request)\n {\n var config = await _context.McpConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.Name = request.Name; config.Description = request.Description;\n config.ServerUrl = request.ServerUrl;\n if (request.ApiKey != null) config.ApiKey = request.ApiKey;\n config.IsActive = request.IsActive; config.SortOrder = request.SortOrder;\n config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteMcpConfigAsync(string id)\n {\n var config = await _context.McpConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<List<SkillConfigDto>> GetSkillConfigsAsync()\n {\n var skills = await _context.SkillConfigs\n .Where(s => !s.IsDeleted)\n .OrderBy(s => s.SortOrder)\n .ThenBy(s => s.Name)\n .ToListAsync();\n\n var result = new List<SkillConfigDto>(skills.Count);\n foreach (var skill in skills)\n {\n var dto = MapSkillConfig(skill);\n dto.Frontmatter = await LoadSkillFrontmatterAsync(skill);\n result.Add(dto);\n }\n\n return result;\n }\n\n public async Task<SkillDetailDto?> GetSkillDetailAsync(string id)\n {\n var skill = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (skill == null) return null;\n var skillPath = Path.Combine(_skillsBasePath, skill.FolderPath);\n var detail = new SkillDetailDto(MapSkillConfig(skill));\n detail.Frontmatter = await LoadSkillFrontmatterAsync(skill);\n var skillMdPath = Path.Combine(skillPath, \"SKILL.md\");\n if (File.Exists(skillMdPath)) detail.SkillMdContent = await File.ReadAllTextAsync(skillMdPath);\n detail.Scripts = ListDirectoryFiles(Path.Combine(skillPath, \"scripts\"));\n detail.References = ListDirectoryFiles(Path.Combine(skillPath, \"references\"));\n detail.Assets = ListDirectoryFiles(Path.Combine(skillPath, \"assets\"));\n return detail;\n }\n\n private SkillConfigDto MapSkillConfig(SkillConfig skill)\n {\n return new SkillConfigDto\n {\n Id = skill.Id,\n Name = skill.Name,\n Description = skill.Description,\n License = skill.License,\n Compatibility = skill.Compatibility,\n AllowedTools = skill.AllowedTools,\n FolderPath = skill.FolderPath,\n IsActive = skill.IsActive,\n SortOrder = skill.SortOrder,\n Author = skill.Author,\n Version = skill.Version,\n Source = skill.Source.ToString().ToLower(),\n SourceUrl = skill.SourceUrl,\n HasScripts = skill.HasScripts,\n HasReferences = skill.HasReferences,\n HasAssets = skill.HasAssets,\n SkillMdSize = skill.SkillMdSize,\n TotalSize = skill.TotalSize,\n CreatedAt = skill.CreatedAt\n };\n }\n\n private async Task<Dictionary<string, object?>> LoadSkillFrontmatterAsync(SkillConfig skill)\n {\n try\n {\n var skillMdPath = Path.Combine(_skillsBasePath, skill.FolderPath, \"SKILL.md\");\n if (!File.Exists(skillMdPath))\n {\n return new Dictionary<string, object?>();\n }\n\n var content = await File.ReadAllTextAsync(skillMdPath);\n var (frontmatter, _) = ParseSkillMd(content);\n return frontmatter;\n }\n catch (Exception ex)\n {\n _logger.LogWarning(ex, \"Failed to load SKILL.md frontmatter for {Skill}\", skill.Name);\n return new Dictionary<string, object?>();\n }\n }\n\n\n public async Task<SkillConfigDto> UploadSkillAsync(Stream zipStream, string fileName)\n {\n var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());\n Directory.CreateDirectory(tempDir);\n try\n {\n using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Read))\n archive.ExtractToDirectory(tempDir);\n var skillMdPath = FindSkillMd(tempDir) ?? throw new InvalidOperationException(\"压缩包中未找到 SKILL.md\");\n var skillRootDir = Path.GetDirectoryName(skillMdPath)!;\n var (frontmatter, _) = ParseSkillMd(await File.ReadAllTextAsync(skillMdPath));\n if (!frontmatter.TryGetValue(\"name\", out var nameObj) || string.IsNullOrEmpty(nameObj?.ToString()))\n throw new InvalidOperationException(\"SKILL.md 缺少 name 字段\");\n var name = nameObj.ToString()!;\n if (!Regex.IsMatch(name, @\"^[a-z0-9]+(-[a-z0-9]+)*$\"))\n throw new InvalidOperationException(\"name 格式无效\");\n if (!frontmatter.TryGetValue(\"description\", out var descObj) || string.IsNullOrEmpty(descObj?.ToString()))\n throw new InvalidOperationException(\"SKILL.md 缺少 description 字段\");\n if (await _context.SkillConfigs.AnyAsync(s => s.Name == name && !s.IsDeleted))\n throw new InvalidOperationException($\"已存在同名 Skill: {name}\");\n var targetPath = Path.Combine(_skillsBasePath, name);\n if (Directory.Exists(targetPath)) Directory.Delete(targetPath, true);\n Directory.Move(skillRootDir, targetPath);\n var config = new SkillConfig\n {\n Id = Guid.NewGuid().ToString(), Name = name, Description = descObj.ToString()!,\n License = frontmatter.TryGetValue(\"license\", out var l) ? l?.ToString() : null,\n Compatibility = frontmatter.TryGetValue(\"compatibility\", out var c) ? c?.ToString() : null,\n AllowedTools = frontmatter.TryGetValue(\"allowed-tools\", out var t) ? t?.ToString() : null,\n FolderPath = name, IsActive = true, SortOrder = 0, Version = \"1.0.0\", Source = SkillSource.Local,\n HasScripts = Directory.Exists(Path.Combine(targetPath, \"scripts\")),\n HasReferences = Directory.Exists(Path.Combine(targetPath, \"references\")),\n HasAssets = Directory.Exists(Path.Combine(targetPath, \"assets\")),\n SkillMdSize = new FileInfo(Path.Combine(targetPath, \"SKILL.md\")).Length,\n TotalSize = CalculateDirectorySize(targetPath), CreatedAt = DateTime.UtcNow\n };\n if (frontmatter.TryGetValue(\"metadata\", out var meta) && meta is Dictionary<object, object> metaDict)\n {\n if (metaDict.TryGetValue(\"author\", out var a)) config.Author = a?.ToString();\n if (metaDict.TryGetValue(\"version\", out var v)) config.Version = v?.ToString() ?? \"1.0.0\";\n }\n _context.SkillConfigs.Add(config);\n await _context.SaveChangesAsync();\n _logger.LogInformation(\"上传 Skill: {Name}\", name);\n return new SkillConfigDto\n {\n Id = config.Id, Name = config.Name, Description = config.Description, License = config.License,\n Compatibility = config.Compatibility, AllowedTools = config.AllowedTools, FolderPath = config.FolderPath,\n IsActive = config.IsActive, SortOrder = config.SortOrder, Author = config.Author, Version = config.Version,\n Source = config.Source.ToString().ToLower(), HasScripts = config.HasScripts,\n HasReferences = config.HasReferences, HasAssets = config.HasAssets,\n SkillMdSize = config.SkillMdSize, TotalSize = config.TotalSize, CreatedAt = config.CreatedAt\n };\n }\n finally { if (Directory.Exists(tempDir)) try { Directory.Delete(tempDir, true); } catch { } }\n }\n\n public async Task<bool> UpdateSkillAsync(string id, SkillUpdateRequest request)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return false;\n if (request.IsActive.HasValue) config.IsActive = request.IsActive.Value;\n if (request.SortOrder.HasValue) config.SortOrder = request.SortOrder.Value;\n config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteSkillAsync(string id)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return false;\n var skillPath = Path.Combine(_skillsBasePath, config.FolderPath);\n if (Directory.Exists(skillPath)) Directory.Delete(skillPath, true);\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n _logger.LogInformation(\"删除 Skill: {Name}\", config.Name);\n return true;\n }\n\n public async Task<string?> GetSkillFileContentAsync(string id, string filePath)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return null;\n var normalizedPath = Path.GetFullPath(Path.Combine(_skillsBasePath, config.FolderPath, filePath));\n var skillBasePath = Path.GetFullPath(Path.Combine(_skillsBasePath, config.FolderPath));\n if (!normalizedPath.StartsWith(skillBasePath)) throw new UnauthorizedAccessException(\"非法路径\");\n return File.Exists(normalizedPath) ? await File.ReadAllTextAsync(normalizedPath) : null;\n }\n\n\n public async Task RefreshSkillsFromDiskAsync()\n {\n if (!Directory.Exists(_skillsBasePath)) return;\n var existingNames = (await _context.SkillConfigs.Where(s => !s.IsDeleted).ToListAsync()).Select(s => s.Name).ToHashSet();\n foreach (var dir in Directory.GetDirectories(_skillsBasePath))\n {\n var skillMdPath = Path.Combine(dir, \"SKILL.md\");\n if (!File.Exists(skillMdPath)) continue;\n var folderName = Path.GetFileName(dir);\n if (existingNames.Contains(folderName)) continue;\n try\n {\n var (frontmatter, _) = ParseSkillMd(await File.ReadAllTextAsync(skillMdPath));\n if (!frontmatter.TryGetValue(\"name\", out var nameObj)) continue;\n var name = nameObj?.ToString();\n if (string.IsNullOrEmpty(name) || name != folderName) continue;\n if (!frontmatter.TryGetValue(\"description\", out var descObj)) continue;\n var config = new SkillConfig\n {\n Id = Guid.NewGuid().ToString(), Name = name, Description = descObj?.ToString() ?? \"\",\n License = frontmatter.TryGetValue(\"license\", out var l) ? l?.ToString() : null,\n Compatibility = frontmatter.TryGetValue(\"compatibility\", out var c) ? c?.ToString() : null,\n AllowedTools = frontmatter.TryGetValue(\"allowed-tools\", out var t) ? t?.ToString() : null,\n FolderPath = folderName, IsActive = true,\n HasScripts = Directory.Exists(Path.Combine(dir, \"scripts\")),\n HasReferences = Directory.Exists(Path.Combine(dir, \"references\")),\n HasAssets = Directory.Exists(Path.Combine(dir, \"assets\")),\n SkillMdSize = new FileInfo(skillMdPath).Length,\n TotalSize = CalculateDirectorySize(dir), CreatedAt = DateTime.UtcNow\n };\n _context.SkillConfigs.Add(config);\n _logger.LogInformation(\"发现 Skill: {Name}\", name);\n }\n catch (Exception ex) { _logger.LogWarning(ex, \"解析失败: {Path}\", dir); }\n }\n await _context.SaveChangesAsync();\n }\n\n public async Task<List<ModelConfigDto>> GetModelConfigsAsync()\n {\n return await _context.ModelConfigs.Where(m => !m.IsDeleted).OrderByDescending(m => m.IsDefault).ThenBy(m => m.Name)\n .Select(m => new ModelConfigDto\n {\n Id = m.Id, Name = m.Name, Provider = m.Provider, ModelId = m.ModelId, Endpoint = m.Endpoint,\n HasApiKey = !string.IsNullOrEmpty(m.ApiKey), IsDefault = m.IsDefault, IsActive = m.IsActive,\n Description = m.Description, CreatedAt = m.CreatedAt\n }).ToListAsync();\n }\n\n public async Task<ModelConfigDto> CreateModelConfigAsync(ModelConfigRequest request)\n {\n var config = new ModelConfig\n {\n Id = Guid.NewGuid().ToString(), Name = request.Name, Provider = request.Provider,\n ModelId = request.ModelId, Endpoint = request.Endpoint, ApiKey = request.ApiKey,\n IsDefault = request.IsDefault, IsActive = request.IsActive, Description = request.Description,\n CreatedAt = DateTime.UtcNow\n };\n _context.ModelConfigs.Add(config);\n await _context.SaveChangesAsync();\n return new ModelConfigDto\n {\n Id = config.Id, Name = config.Name, Provider = config.Provider, ModelId = config.ModelId,\n Endpoint = config.Endpoint, HasApiKey = !string.IsNullOrEmpty(config.ApiKey),\n IsDefault = config.IsDefault, IsActive = config.IsActive, Description = config.Description,\n CreatedAt = config.CreatedAt\n };\n }\n\n public async Task<bool> UpdateModelConfigAsync(string id, ModelConfigRequest request)\n {\n var config = await _context.ModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.Name = request.Name; config.Provider = request.Provider; config.ModelId = request.ModelId;\n config.Endpoint = request.Endpoint;\n if (request.ApiKey != null) config.ApiKey = request.ApiKey;\n config.IsDefault = request.IsDefault; config.IsActive = request.IsActive;\n config.Description = request.Description; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteModelConfigAsync(string id)\n {\n var config = await _context.ModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n private static string? FindSkillMd(string directory)\n {\n var skillMd = Path.Combine(directory, \"SKILL.md\");\n if (File.Exists(skillMd)) return skillMd;\n foreach (var subDir in Directory.GetDirectories(directory))\n {\n skillMd = Path.Combine(subDir, \"SKILL.md\");\n if (File.Exists(skillMd)) return skillMd;\n }\n return null;\n }\n\n private static (Dictionary<string, object?> frontmatter, string body) ParseSkillMd(string content)\n {\n var frontmatter = new Dictionary<string, object?>();\n var body = content;\n if (content.StartsWith(\"---\"))\n {\n var endIndex = content.IndexOf(\"---\", 3);\n if (endIndex > 0)\n {\n var yamlContent = content[3..endIndex].Trim();\n body = content[(endIndex + 3)..].Trim();\n try\n {\n var deserializer = new DeserializerBuilder().WithNamingConvention(HyphenatedNamingConvention.Instance).Build();\n frontmatter = deserializer.Deserialize<Dictionary<string, object?>>(yamlContent) ?? new();\n }\n catch { }\n }\n }\n return (frontmatter, body);\n }\n\n private static List<SkillFileInfo> ListDirectoryFiles(string directory)\n {\n var files = new List<SkillFileInfo>();\n if (!Directory.Exists(directory)) return files;\n foreach (var file in Directory.GetFiles(directory, \"*\", SearchOption.AllDirectories))\n {\n var info = new FileInfo(file);\n files.Add(new SkillFileInfo { FileName = info.Name, RelativePath = Path.GetRelativePath(directory, file), Size = info.Length, LastModified = info.LastWriteTimeUtc });\n }\n return files;\n }\n\n private static long CalculateDirectorySize(string directory) =>\n !Directory.Exists(directory) ? 0 : Directory.GetFiles(directory, \"*\", SearchOption.AllDirectories).Sum(f => new FileInfo(f).Length);\n}\n"}, "cfa0cc80cf86b9e32124f8fc157e83aa5dbc0757": {"t": 1785760378.9118087, "n": 18029, "h": "3d5eeace6d4914eb", "c": "using System.IO.Compression;\nusing System.Text.RegularExpressions;\nusing Microsoft.EntityFrameworkCore;\nusing OpenDeepWiki.EFCore;\nusing OpenDeepWiki.Entities;\nusing OpenDeepWiki.Entities.Tools;\nusing OpenDeepWiki.Models.Admin;\nusing YamlDotNet.Serialization;\nusing YamlDotNet.Serialization.NamingConventions;\n\nnamespace OpenDeepWiki.Services.Admin;\n\npublic class AdminToolsService : IAdminToolsService\n{\n private readonly IContext _context;\n private readonly ILogger<AdminToolsService> _logger;\n private readonly string _skillsBasePath;\n\n public AdminToolsService(IContext context, ILogger<AdminToolsService> logger, IConfiguration configuration)\n {\n _context = context;\n _logger = logger;\n _skillsBasePath = configuration[\"Skills:BasePath\"] ?? Path.Combine(AppContext.BaseDirectory, \"skills\");\n if (!Directory.Exists(_skillsBasePath)) Directory.CreateDirectory(_skillsBasePath);\n }\n\n public async Task<List<McpConfigDto>> GetMcpConfigsAsync()\n {\n return await _context.McpConfigs.Where(m => !m.IsDeleted).OrderBy(m => m.SortOrder)\n .Select(m => new McpConfigDto\n {\n Id = m.Id, Name = m.Name, Description = m.Description, ServerUrl = m.ServerUrl,\n HasApiKey = !string.IsNullOrEmpty(m.ApiKey), IsActive = m.IsActive,\n SortOrder = m.SortOrder, CreatedAt = m.CreatedAt\n }).ToListAsync();\n }\n\n public async Task<McpConfigDto> CreateMcpConfigAsync(McpConfigRequest request)\n {\n var config = new McpConfig\n {\n Id = Guid.NewGuid().ToString(), Name = request.Name, Description = request.Description,\n ServerUrl = request.ServerUrl, ApiKey = request.ApiKey, IsActive = request.IsActive,\n SortOrder = request.SortOrder, CreatedAt = DateTime.UtcNow\n };\n _context.McpConfigs.Add(config);\n await _context.SaveChangesAsync();\n return new McpConfigDto\n {\n Id = config.Id, Name = config.Name, Description = config.Description,\n ServerUrl = config.ServerUrl, HasApiKey = !string.IsNullOrEmpty(config.ApiKey),\n IsActive = config.IsActive, SortOrder = config.SortOrder, CreatedAt = config.CreatedAt\n };\n }\n\n\n public async Task<bool> UpdateMcpConfigAsync(string id, McpConfigRequest request)\n {\n var config = await _context.McpConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.Name = request.Name; config.Description = request.Description;\n config.ServerUrl = request.ServerUrl;\n if (request.ApiKey != null) config.ApiKey = request.ApiKey;\n config.IsActive = request.IsActive; config.SortOrder = request.SortOrder;\n config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteMcpConfigAsync(string id)\n {\n var config = await _context.McpConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<List<SkillConfigDto>> GetSkillConfigsAsync()\n {\n return await _context.SkillConfigs.Where(s => !s.IsDeleted).OrderBy(s => s.SortOrder).ThenBy(s => s.Name)\n .Select(s => new SkillConfigDto\n {\n Id = s.Id, Name = s.Name, Description = s.Description, License = s.License,\n Compatibility = s.Compatibility, AllowedTools = s.AllowedTools, FolderPath = s.FolderPath,\n IsActive = s.IsActive, SortOrder = s.SortOrder, Author = s.Author, Version = s.Version,\n Source = s.Source.ToString().ToLower(), SourceUrl = s.SourceUrl,\n HasScripts = s.HasScripts, HasReferences = s.HasReferences, HasAssets = s.HasAssets,\n SkillMdSize = s.SkillMdSize, TotalSize = s.TotalSize, CreatedAt = s.CreatedAt\n }).ToListAsync();\n }\n\n public async Task<SkillDetailDto?> GetSkillDetailAsync(string id)\n {\n var skill = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (skill == null) return null;\n var skillPath = Path.Combine(_skillsBasePath, skill.FolderPath);\n var detail = new SkillDetailDto\n {\n Id = skill.Id, Name = skill.Name, Description = skill.Description, License = skill.License,\n Compatibility = skill.Compatibility, AllowedTools = skill.AllowedTools, FolderPath = skill.FolderPath,\n IsActive = skill.IsActive, SortOrder = skill.SortOrder, Author = skill.Author, Version = skill.Version,\n Source = skill.Source.ToString().ToLower(), SourceUrl = skill.SourceUrl,\n HasScripts = skill.HasScripts, HasReferences = skill.HasReferences, HasAssets = skill.HasAssets,\n SkillMdSize = skill.SkillMdSize, TotalSize = skill.TotalSize, CreatedAt = skill.CreatedAt\n };\n var skillMdPath = Path.Combine(skillPath, \"SKILL.md\");\n if (File.Exists(skillMdPath)) detail.SkillMdContent = await File.ReadAllTextAsync(skillMdPath);\n detail.Scripts = ListDirectoryFiles(Path.Combine(skillPath, \"scripts\"));\n detail.References = ListDirectoryFiles(Path.Combine(skillPath, \"references\"));\n detail.Assets = ListDirectoryFiles(Path.Combine(skillPath, \"assets\"));\n return detail;\n }\n\n\n public async Task<SkillConfigDto> UploadSkillAsync(Stream zipStream, string fileName)\n {\n var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());\n Directory.CreateDirectory(tempDir);\n try\n {\n using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Read))\n archive.ExtractToDirectory(tempDir);\n var skillMdPath = FindSkillMd(tempDir) ?? throw new InvalidOperationException(\"压缩包中未找到 SKILL.md\");\n var skillRootDir = Path.GetDirectoryName(skillMdPath)!;\n var (frontmatter, _) = ParseSkillMd(await File.ReadAllTextAsync(skillMdPath));\n if (!frontmatter.TryGetValue(\"name\", out var nameObj) || string.IsNullOrEmpty(nameObj?.ToString()))\n throw new InvalidOperationException(\"SKILL.md 缺少 name 字段\");\n var name = nameObj.ToString()!;\n if (!Regex.IsMatch(name, @\"^[a-z0-9]+(-[a-z0-9]+)*$\"))\n throw new InvalidOperationException(\"name 格式无效\");\n if (!frontmatter.TryGetValue(\"description\", out var descObj) || string.IsNullOrEmpty(descObj?.ToString()))\n throw new InvalidOperationException(\"SKILL.md 缺少 description 字段\");\n if (await _context.SkillConfigs.AnyAsync(s => s.Name == name && !s.IsDeleted))\n throw new InvalidOperationException($\"已存在同名 Skill: {name}\");\n var targetPath = Path.Combine(_skillsBasePath, name);\n if (Directory.Exists(targetPath)) Directory.Delete(targetPath, true);\n Directory.Move(skillRootDir, targetPath);\n var config = new SkillConfig\n {\n Id = Guid.NewGuid().ToString(), Name = name, Description = descObj.ToString()!,\n License = frontmatter.TryGetValue(\"license\", out var l) ? l?.ToString() : null,\n Compatibility = frontmatter.TryGetValue(\"compatibility\", out var c) ? c?.ToString() : null,\n AllowedTools = frontmatter.TryGetValue(\"allowed-tools\", out var t) ? t?.ToString() : null,\n FolderPath = name, IsActive = true, SortOrder = 0, Version = \"1.0.0\", Source = SkillSource.Local,\n HasScripts = Directory.Exists(Path.Combine(targetPath, \"scripts\")),\n HasReferences = Directory.Exists(Path.Combine(targetPath, \"references\")),\n HasAssets = Directory.Exists(Path.Combine(targetPath, \"assets\")),\n SkillMdSize = new FileInfo(Path.Combine(targetPath, \"SKILL.md\")).Length,\n TotalSize = CalculateDirectorySize(targetPath), CreatedAt = DateTime.UtcNow\n };\n if (frontmatter.TryGetValue(\"metadata\", out var meta) && meta is Dictionary<object, object> metaDict)\n {\n if (metaDict.TryGetValue(\"author\", out var a)) config.Author = a?.ToString();\n if (metaDict.TryGetValue(\"version\", out var v)) config.Version = v?.ToString() ?? \"1.0.0\";\n }\n _context.SkillConfigs.Add(config);\n await _context.SaveChangesAsync();\n _logger.LogInformation(\"上传 Skill: {Name}\", name);\n return new SkillConfigDto\n {\n Id = config.Id, Name = config.Name, Description = config.Description, License = config.License,\n Compatibility = config.Compatibility, AllowedTools = config.AllowedTools, FolderPath = config.FolderPath,\n IsActive = config.IsActive, SortOrder = config.SortOrder, Author = config.Author, Version = config.Version,\n Source = config.Source.ToString().ToLower(), HasScripts = config.HasScripts,\n HasReferences = config.HasReferences, HasAssets = config.HasAssets,\n SkillMdSize = config.SkillMdSize, TotalSize = config.TotalSize, CreatedAt = config.CreatedAt\n };\n }\n finally { if (Directory.Exists(tempDir)) try { Directory.Delete(tempDir, true); } catch { } }\n }\n\n public async Task<bool> UpdateSkillAsync(string id, SkillUpdateRequest request)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return false;\n if (request.IsActive.HasValue) config.IsActive = request.IsActive.Value;\n if (request.SortOrder.HasValue) config.SortOrder = request.SortOrder.Value;\n config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteSkillAsync(string id)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return false;\n var skillPath = Path.Combine(_skillsBasePath, config.FolderPath);\n if (Directory.Exists(skillPath)) Directory.Delete(skillPath, true);\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n _logger.LogInformation(\"删除 Skill: {Name}\", config.Name);\n return true;\n }\n\n public async Task<string?> GetSkillFileContentAsync(string id, string filePath)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return null;\n var normalizedPath = Path.GetFullPath(Path.Combine(_skillsBasePath, config.FolderPath, filePath));\n var skillBasePath = Path.GetFullPath(Path.Combine(_skillsBasePath, config.FolderPath));\n if (!normalizedPath.StartsWith(skillBasePath)) throw new UnauthorizedAccessException(\"非法路径\");\n return File.Exists(normalizedPath) ? await File.ReadAllTextAsync(normalizedPath) : null;\n }\n\n\n public async Task RefreshSkillsFromDiskAsync()\n {\n if (!Directory.Exists(_skillsBasePath)) return;\n var existingNames = (await _context.SkillConfigs.Where(s => !s.IsDeleted).ToListAsync()).Select(s => s.Name).ToHashSet();\n foreach (var dir in Directory.GetDirectories(_skillsBasePath))\n {\n var skillMdPath = Path.Combine(dir, \"SKILL.md\");\n if (!File.Exists(skillMdPath)) continue;\n var folderName = Path.GetFileName(dir);\n if (existingNames.Contains(folderName)) continue;\n try\n {\n var (frontmatter, _) = ParseSkillMd(await File.ReadAllTextAsync(skillMdPath));\n if (!frontmatter.TryGetValue(\"name\", out var nameObj)) continue;\n var name = nameObj?.ToString();\n if (string.IsNullOrEmpty(name) || name != folderName) continue;\n if (!frontmatter.TryGetValue(\"description\", out var descObj)) continue;\n var config = new SkillConfig\n {\n Id = Guid.NewGuid().ToString(), Name = name, Description = descObj?.ToString() ?? \"\",\n License = frontmatter.TryGetValue(\"license\", out var l) ? l?.ToString() : null,\n Compatibility = frontmatter.TryGetValue(\"compatibility\", out var c) ? c?.ToString() : null,\n AllowedTools = frontmatter.TryGetValue(\"allowed-tools\", out var t) ? t?.ToString() : null,\n FolderPath = folderName, IsActive = true,\n HasScripts = Directory.Exists(Path.Combine(dir, \"scripts\")),\n HasReferences = Directory.Exists(Path.Combine(dir, \"references\")),\n HasAssets = Directory.Exists(Path.Combine(dir, \"assets\")),\n SkillMdSize = new FileInfo(skillMdPath).Length,\n TotalSize = CalculateDirectorySize(dir), CreatedAt = DateTime.UtcNow\n };\n _context.SkillConfigs.Add(config);\n _logger.LogInformation(\"发现 Skill: {Name}\", name);\n }\n catch (Exception ex) { _logger.LogWarning(ex, \"解析失败: {Path}\", dir); }\n }\n await _context.SaveChangesAsync();\n }\n\n public async Task<List<ModelConfigDto>> GetModelConfigsAsync()\n {\n return await _context.ModelConfigs.Where(m => !m.IsDeleted).OrderByDescending(m => m.IsDefault).ThenBy(m => m.Name)\n .Select(m => new ModelConfigDto\n {\n Id = m.Id, Name = m.Name, Provider = m.Provider, ModelId = m.ModelId, Endpoint = m.Endpoint,\n HasApiKey = !string.IsNullOrEmpty(m.ApiKey), IsDefault = m.IsDefault, IsActive = m.IsActive,\n Description = m.Description, CreatedAt = m.CreatedAt\n }).ToListAsync();\n }\n\n public async Task<ModelConfigDto> CreateModelConfigAsync(ModelConfigRequest request)\n {\n var config = new ModelConfig\n {\n Id = Guid.NewGuid().ToString(), Name = request.Name, Provider = request.Provider,\n ModelId = request.ModelId, Endpoint = request.Endpoint, ApiKey = request.ApiKey,\n IsDefault = request.IsDefault, IsActive = request.IsActive, Description = request.Description,\n CreatedAt = DateTime.UtcNow\n };\n _context.ModelConfigs.Add(config);\n await _context.SaveChangesAsync();\n return new ModelConfigDto\n {\n Id = config.Id, Name = config.Name, Provider = config.Provider, ModelId = config.ModelId,\n Endpoint = config.Endpoint, HasApiKey = !string.IsNullOrEmpty(config.ApiKey),\n IsDefault = config.IsDefault, IsActive = config.IsActive, Description = config.Description,\n CreatedAt = config.CreatedAt\n };\n }\n\n public async Task<bool> UpdateModelConfigAsync(string id, ModelConfigRequest request)\n {\n var config = await _context.ModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.Name = request.Name; config.Provider = request.Provider; config.ModelId = request.ModelId;\n config.Endpoint = request.Endpoint;\n if (request.ApiKey != null) config.ApiKey = request.ApiKey;\n config.IsDefault = request.IsDefault; config.IsActive = request.IsActive;\n config.Description = request.Description; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteModelConfigAsync(string id)\n {\n var config = await _context.ModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n config.IsDeleted = true; config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n private static string? FindSkillMd(string directory)\n {\n var skillMd = Path.Combine(directory, \"SKILL.md\");\n if (File.Exists(skillMd)) return skillMd;\n foreach (var subDir in Directory.GetDirectories(directory))\n {\n skillMd = Path.Combine(subDir, \"SKILL.md\");\n if (File.Exists(skillMd)) return skillMd;\n }\n return null;\n }\n\n private static (Dictionary<string, object?> frontmatter, string body) ParseSkillMd(string content)\n {\n var frontmatter = new Dictionary<string, object?>();\n var body = content;\n if (content.StartsWith(\"---\"))\n {\n var endIndex = content.IndexOf(\"---\", 3);\n if (endIndex > 0)\n {\n var yamlContent = content[3..endIndex].Trim();\n body = content[(endIndex + 3)..].Trim();\n try\n {\n var deserializer = new DeserializerBuilder().WithNamingConvention(HyphenatedNamingConvention.Instance).Build();\n frontmatter = deserializer.Deserialize<Dictionary<string, object?>>(yamlContent) ?? new();\n }\n catch { }\n }\n }\n return (frontmatter, body);\n }\n\n private static List<SkillFileInfo> ListDirectoryFiles(string directory)\n {\n var files = new List<SkillFileInfo>();\n if (!Directory.Exists(directory)) return files;\n foreach (var file in Directory.GetFiles(directory, \"*\", SearchOption.AllDirectories))\n {\n var info = new FileInfo(file);\n files.Add(new SkillFileInfo { FileName = info.Name, RelativePath = Path.GetRelativePath(directory, file), Size = info.Length, LastModified = info.LastWriteTimeUtc });\n }\n return files;\n }\n\n private static long CalculateDirectorySize(string directory) =>\n !Directory.Exists(directory) ? 0 : Directory.GetFiles(directory, \"*\", SearchOption.AllDirectories).Sum(f => new FileInfo(f).Length);\n}\n"}, "5dcff93f5215a5259e39f6359bed754c177789d6": {"t": 1785760380.7009604, "n": 8440, "h": "48f8a9903e47a0b9", "c": "using Microsoft.EntityFrameworkCore;\nusing OpenDeepWiki.EFCore;\nusing OpenDeepWiki.Entities;\nusing OpenDeepWiki.Models.Admin;\n\nnamespace OpenDeepWiki.Services.Admin;\n\n/// <summary>\n/// 管理端工具配置服务实现\n/// </summary>\npublic class AdminToolsService : IAdminToolsService\n{\n private readonly IContext _context;\n\n public AdminToolsService(IContext context)\n {\n _context = context;\n }\n\n #region MCP 配置\n\n public async Task<List<McpConfigDto>> GetMcpConfigsAsync()\n {\n return await _context.McpConfigs\n .Where(m => !m.IsDeleted)\n .OrderBy(m => m.SortOrder)\n .Select(m => new McpConfigDto\n {\n Id = m.Id,\n Name = m.Name,\n Description = m.Description,\n ServerUrl = m.ServerUrl,\n HasApiKey = !string.IsNullOrEmpty(m.ApiKey),\n IsActive = m.IsActive,\n SortOrder = m.SortOrder,\n CreatedAt = m.CreatedAt\n })\n .ToListAsync();\n }\n\n public async Task<McpConfigDto> CreateMcpConfigAsync(McpConfigRequest request)\n {\n var config = new McpConfig\n {\n Id = Guid.NewGuid().ToString(),\n Name = request.Name,\n Description = request.Description,\n ServerUrl = request.ServerUrl,\n ApiKey = request.ApiKey,\n IsActive = request.IsActive,\n SortOrder = request.SortOrder,\n CreatedAt = DateTime.UtcNow\n };\n\n _context.McpConfigs.Add(config);\n await _context.SaveChangesAsync();\n\n return new McpConfigDto\n {\n Id = config.Id,\n Name = config.Name,\n Description = config.Description,\n ServerUrl = config.ServerUrl,\n HasApiKey = !string.IsNullOrEmpty(config.ApiKey),\n IsActive = config.IsActive,\n SortOrder = config.SortOrder,\n CreatedAt = config.CreatedAt\n };\n }\n\n public async Task<bool> UpdateMcpConfigAsync(string id, McpConfigRequest request)\n {\n var config = await _context.McpConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n\n config.Name = request.Name;\n config.Description = request.Description;\n config.ServerUrl = request.ServerUrl;\n if (request.ApiKey != null) config.ApiKey = request.ApiKey;\n config.IsActive = request.IsActive;\n config.SortOrder = request.SortOrder;\n config.UpdatedAt = DateTime.UtcNow;\n\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteMcpConfigAsync(string id)\n {\n var config = await _context.McpConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n\n config.IsDeleted = true;\n config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n #endregion\n\n #region Skill 配置\n\n public async Task<List<SkillConfigDto>> GetSkillConfigsAsync()\n {\n return await _context.SkillConfigs\n .Where(s => !s.IsDeleted)\n .OrderBy(s => s.SortOrder)\n .Select(s => new SkillConfigDto\n {\n Id = s.Id,\n Name = s.Name,\n Description = s.Description,\n PromptTemplate = s.PromptTemplate,\n IsActive = s.IsActive,\n SortOrder = s.SortOrder,\n CreatedAt = s.CreatedAt\n })\n .ToListAsync();\n }\n\n public async Task<SkillConfigDto> CreateSkillConfigAsync(SkillConfigRequest request)\n {\n var config = new SkillConfig\n {\n Id = Guid.NewGuid().ToString(),\n Name = request.Name,\n Description = request.Description,\n PromptTemplate = request.PromptTemplate,\n IsActive = request.IsActive,\n SortOrder = request.SortOrder,\n CreatedAt = DateTime.UtcNow\n };\n\n _context.SkillConfigs.Add(config);\n await _context.SaveChangesAsync();\n\n return new SkillConfigDto\n {\n Id = config.Id,\n Name = config.Name,\n Description = config.Description,\n PromptTemplate = config.PromptTemplate,\n IsActive = config.IsActive,\n SortOrder = config.SortOrder,\n CreatedAt = config.CreatedAt\n };\n }\n\n public async Task<bool> UpdateSkillConfigAsync(string id, SkillConfigRequest request)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return false;\n\n config.Name = request.Name;\n config.Description = request.Description;\n config.PromptTemplate = request.PromptTemplate;\n config.IsActive = request.IsActive;\n config.SortOrder = request.SortOrder;\n config.UpdatedAt = DateTime.UtcNow;\n\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteSkillConfigAsync(string id)\n {\n var config = await _context.SkillConfigs.FirstOrDefaultAsync(s => s.Id == id && !s.IsDeleted);\n if (config == null) return false;\n\n config.IsDeleted = true;\n config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n #endregion\n\n #region 模型配置\n\n public async Task<List<ModelConfigDto>> GetModelConfigsAsync()\n {\n return await _context.ModelConfigs\n .Where(m => !m.IsDeleted)\n .OrderByDescending(m => m.IsDefault)\n .ThenBy(m => m.Name)\n .Select(m => new ModelConfigDto\n {\n Id = m.Id,\n Name = m.Name,\n Provider = m.Provider,\n ModelId = m.ModelId,\n Endpoint = m.Endpoint,\n HasApiKey = !string.IsNullOrEmpty(m.ApiKey),\n IsDefault = m.IsDefault,\n IsActive = m.IsActive,\n Description = m.Description,\n CreatedAt = m.CreatedAt\n })\n .ToListAsync();\n }\n\n public async Task<ModelConfigDto> CreateModelConfigAsync(ModelConfigRequest request)\n {\n var config = new ModelConfig\n {\n Id = Guid.NewGuid().ToString(),\n Name = request.Name,\n Provider = request.Provider,\n ModelId = request.ModelId,\n Endpoint = request.Endpoint,\n ApiKey = request.ApiKey,\n IsDefault = request.IsDefault,\n IsActive = request.IsActive,\n Description = request.Description,\n CreatedAt = DateTime.UtcNow\n };\n\n _context.ModelConfigs.Add(config);\n await _context.SaveChangesAsync();\n\n return new ModelConfigDto\n {\n Id = config.Id,\n Name = config.Name,\n Provider = config.Provider,\n ModelId = config.ModelId,\n Endpoint = config.Endpoint,\n HasApiKey = !string.IsNullOrEmpty(config.ApiKey),\n IsDefault = config.IsDefault,\n IsActive = config.IsActive,\n Description = config.Description,\n CreatedAt = config.CreatedAt\n };\n }\n\n public async Task<bool> UpdateModelConfigAsync(string id, ModelConfigRequest request)\n {\n var config = await _context.ModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n\n config.Name = request.Name;\n config.Provider = request.Provider;\n config.ModelId = request.ModelId;\n config.Endpoint = request.Endpoint;\n if (request.ApiKey != null) config.ApiKey = request.ApiKey;\n config.IsDefault = request.IsDefault;\n config.IsActive = request.IsActive;\n config.Description = request.Description;\n config.UpdatedAt = DateTime.UtcNow;\n\n await _context.SaveChangesAsync();\n return true;\n }\n\n public async Task<bool> DeleteModelConfigAsync(string id)\n {\n var config = await _context.ModelConfigs.FirstOrDefaultAsync(m => m.Id == id && !m.IsDeleted);\n if (config == null) return false;\n\n config.IsDeleted = true;\n config.UpdatedAt = DateTime.UtcNow;\n await _context.SaveChangesAsync();\n return true;\n }\n\n #endregion\n}"}}, "tests/OpenDeepWiki.Tests/Agents/DeepSeekOpenAIChatClientTests.cs": {"6eb5222d25487362fcd411891fad9027f7840f96": {"t": 1785746205.0502486, "n": 14234, "h": "87d29f1b6eb2eabe", "c": "using System.Net;\nusing System.Text;\nusing System.Text.Json;\nusing Microsoft.Extensions.AI;\nusing OpenDeepWiki.Agents;\nusing Xunit;\n\nnamespace OpenDeepWiki.Tests.Agents;\n\npublic class DeepSeekOpenAIChatClientTests\n{\n [Fact]\n public async Task GetResponseAsync_AppliesThinkingBodyParamsByDefault()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\n \"id\":\"chatcmpl-test\",\n \"model\":\"deepseek-v4-flash\",\n \"created\":1700000000,\n \"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}],\n \"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3,\"prompt_tokens_details\":{\"cached_tokens\":1}}\n }\n \"\"\"));\n var client = CreateClient(handler);\n\n var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.True(document.RootElement.GetProperty(\"enable_thinking\").GetBoolean());\n Assert.Equal(\"pong\", response.Text);\n Assert.Equal(3, response.Usage?.TotalTokenCount);\n Assert.Equal(1, response.Usage?.CachedInputTokenCount);\n }\n\n [Fact]\n public async Task GetResponseAsync_CanDisableThinkingFromChatOptions()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n\n await client.GetResponseAsync(\n [new ChatMessage(ChatRole.User, \"ping\")],\n new ChatOptions\n {\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"thinkingEnabled\"] = false\n }\n });\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.False(document.RootElement.GetProperty(\"enable_thinking\").GetBoolean());\n }\n\n [Fact]\n public async Task GetResponseAsync_IncludesStablePromptCacheKey()\n {\n var firstHandler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test-1\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var secondHandler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test-2\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var firstClient = CreateClient(firstHandler);\n var secondClient = CreateClient(secondHandler);\n\n await firstClient.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n await secondClient.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n\n using var firstDocument = JsonDocument.Parse(firstHandler.RequestBodies.Single());\n using var secondDocument = JsonDocument.Parse(secondHandler.RequestBodies.Single());\n var firstKey = firstDocument.RootElement.GetProperty(\"prompt_cache_key\").GetString();\n var secondKey = secondDocument.RootElement.GetProperty(\"prompt_cache_key\").GetString();\n\n Assert.False(string.IsNullOrWhiteSpace(firstKey));\n Assert.Equal(firstKey, secondKey);\n Assert.Equal(\"opendeepwiki\", firstKey);\n }\n\n [Fact]\n public async Task GetResponseAsync_UsesPromptCacheKeyFromRequestOptions()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(\n handler,\n new AiRequestOptions\n {\n SupportsThinking = true,\n PromptCacheKey = \"odw:content:repo\"\n });\n\n await client.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.Equal(\"odw:content:repo\", document.RootElement.GetProperty(\"prompt_cache_key\").GetString());\n }\n\n [Fact]\n public async Task GetResponseAsync_ChatOptionsPromptCacheKeyOverridesRequestOptions()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(\n handler,\n new AiRequestOptions\n {\n SupportsThinking = true,\n PromptCacheKey = \"request-key\"\n });\n\n await client.GetResponseAsync(\n [new ChatMessage(ChatRole.User, \"ping\")],\n new ChatOptions\n {\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"promptCacheKey\"] = \"chat-options-key\"\n }\n });\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.Equal(\"chat-options-key\", document.RootElement.GetProperty(\"prompt_cache_key\").GetString());\n }\n\n [Fact]\n public async Task GetResponseAsync_UsesSpecificToolChoice()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"saved\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n var writeDoc = AIFunctionFactory.Create(\n (string content) => $\"saved:{content}\",\n new AIFunctionFactoryOptions\n {\n Name = \"WriteDoc\",\n Description = \"Persist a Markdown document\"\n });\n\n await client.GetResponseAsync(\n [new ChatMessage(ChatRole.User, \"save\")],\n new ChatOptions\n {\n Tools = [writeDoc],\n ToolMode = ChatToolMode.RequireSpecific(\"WriteDoc\")\n });\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n var choice = document.RootElement.GetProperty(\"tool_choice\");\n Assert.Equal(\"function\", choice.GetProperty(\"type\").GetString());\n Assert.Equal(\"WriteDoc\", choice.GetProperty(\"function\").GetProperty(\"name\").GetString());\n }\n\n [Fact]\n public async Task GetResponseAsync_PrependsInstructionsAsSystemMessage()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n\n await client.GetResponseAsync(\n [new ChatMessage(ChatRole.User, \"ping\")],\n new ChatOptions\n {\n Instructions = \"You are a wiki assistant.\"\n });\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n var messages = document.RootElement.GetProperty(\"messages\").EnumerateArray().ToArray();\n Assert.Equal(\"system\", messages[0].GetProperty(\"role\").GetString());\n Assert.Equal(\"You are a wiki assistant.\", messages[0].GetProperty(\"content\").GetString());\n Assert.Equal(\"user\", messages[1].GetProperty(\"role\").GetString());\n }\n\n [Fact]\n public async Task GetStreamingResponseAsync_AcceptsDoneSentinelAndMapsUsage()\n {\n var handler = new StubHttpMessageHandler(_ => EventStreamResponse(\"\"\"\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"hel\"},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":2,\"total_tokens\":4}}\n\n data: DONE\n\n \"\"\"));\n var client = CreateClient(handler);\n var updates = new List<ChatResponseUpdate>();\n\n await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]))\n {\n updates.Add(update);\n }\n\n Assert.Equal(\"hello\", string.Concat(updates.Select(update => update.Text)));\n var usage = updates.SelectMany(update => update.Contents).OfType<UsageContent>().Single().Details;\n Assert.Equal(4, usage.TotalTokenCount);\n }\n\n [Fact]\n public async Task GetStreamingResponseAsync_AccumulatesToolCallDeltas()\n {\n var handler = new StubHttpMessageHandler(_ => EventStreamResponse(\"\"\"\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"\"}}]},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"README.md\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n data: [DONE]\n\n \"\"\"));\n var client = CreateClient(handler);\n var updates = new List<ChatResponseUpdate>();\n\n await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, \"read\")]))\n {\n updates.Add(update);\n }\n\n var functionCall = updates\n .SelectMany(update => update.Contents)\n .OfType<FunctionCallContent>()\n .Single();\n Assert.Equal(\"call_1\", functionCall.CallId);\n Assert.Equal(\"read_file\", functionCall.Name);\n Assert.NotNull(functionCall.Arguments);\n var arguments = functionCall.Arguments!;\n Assert.True(arguments.TryGetValue(\"path\", out var path));\n Assert.Equal(\"README.md\", path);\n }\n\n [Fact]\n public async Task GetStreamingResponseAsync_RoundTripsReasoningContentWithToolCalls()\n {\n var requestIndex = 0;\n var handler = new StubHttpMessageHandler(_ => requestIndex++ == 0\n ? EventStreamResponse(\"\"\"\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"reasoning_content\":\"think \"},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"reasoning_content\":\"more\"},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n data: [DONE]\n\n \"\"\")\n : JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test-2\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n var updates = new List<ChatResponseUpdate>();\n\n await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, \"read\")]))\n {\n updates.Add(update);\n }\n\n var reasoning = string.Concat(updates\n .SelectMany(update => update.Contents)\n .OfType<TextReasoningContent>()\n .Select(content => content.Text));\n Assert.Equal(\"think more\", reasoning);\n\n var conversation = new List<ChatMessage>\n {\n new(ChatRole.User, \"read\")\n };\n conversation.AddMessages(updates);\n conversation.Add(new ChatMessage(\n ChatRole.Tool,\n [new FunctionResultContent(\"call_1\", \"file contents\")]));\n\n await client.GetResponseAsync(conversation);\n\n using var document = JsonDocument.Parse(handler.RequestBodies[1]);\n var messages = document.RootElement.GetProperty(\"messages\").EnumerateArray().ToArray();\n var assistant = messages.Single(message =>\n message.GetProperty(\"role\").GetString() == \"assistant\");\n Assert.Equal(\"think more\", assistant.GetProperty(\"reasoning_content\").GetString());\n Assert.True(assistant.TryGetProperty(\"tool_calls\", out _));\n }\n\n private static DeepSeekOpenAIChatClient CreateClient(\n StubHttpMessageHandler handler,\n AiRequestOptions? options = null)\n {\n options ??= new AiRequestOptions\n {\n SupportsThinking = true,\n ThinkingConfigJson = \"\"\"\n {\n \"bodyParams\":{\"enable_thinking\":true},\n \"disabledBodyParams\":{\"enable_thinking\":false}\n }\n \"\"\"\n };\n\n return new DeepSeekOpenAIChatClient(\n \"deepseek-v4-flash\",\n \"https://api.deepseek.com/v1\",\n \"test-key\",\n new HttpClient(handler),\n options,\n disposeHttpClient: true);\n }\n\n private static HttpResponseMessage JsonResponse(string content)\n {\n return new HttpResponseMessage(HttpStatusCode.OK)\n {\n Content = new StringContent(content, Encoding.UTF8, \"application/json\")\n };\n }\n\n private static HttpResponseMessage EventStreamResponse(string content)\n {\n return new HttpResponseMessage(HttpStatusCode.OK)\n {\n Content = new StringContent(content, Encoding.UTF8, \"text/event-stream\")\n };\n }\n\n private sealed class StubHttpMessageHandler(\n Func<HttpRequestMessage, HttpResponseMessage> handle)\n : HttpMessageHandler\n {\n public List<string> RequestBodies { get; } = [];\n\n protected override async Task<HttpResponseMessage> SendAsync(\n HttpRequestMessage request,\n CancellationToken cancellationToken)\n {\n RequestBodies.Add(request.Content == null\n ? string.Empty\n : await request.Content.ReadAsStringAsync(cancellationToken));\n return handle(request);\n }\n }\n}\n"}, "889b62ad3a932a8b430800378d9f96b05eca4224": {"t": 1785760375.1151369, "n": 14234, "h": "87d29f1b6eb2eabe", "c": "using System.Net;\nusing System.Text;\nusing System.Text.Json;\nusing Microsoft.Extensions.AI;\nusing OpenDeepWiki.Agents;\nusing Xunit;\n\nnamespace OpenDeepWiki.Tests.Agents;\n\npublic class DeepSeekOpenAIChatClientTests\n{\n [Fact]\n public async Task GetResponseAsync_AppliesThinkingBodyParamsByDefault()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\n \"id\":\"chatcmpl-test\",\n \"model\":\"deepseek-v4-flash\",\n \"created\":1700000000,\n \"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}],\n \"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3,\"prompt_tokens_details\":{\"cached_tokens\":1}}\n }\n \"\"\"));\n var client = CreateClient(handler);\n\n var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.True(document.RootElement.GetProperty(\"enable_thinking\").GetBoolean());\n Assert.Equal(\"pong\", response.Text);\n Assert.Equal(3, response.Usage?.TotalTokenCount);\n Assert.Equal(1, response.Usage?.CachedInputTokenCount);\n }\n\n [Fact]\n public async Task GetResponseAsync_CanDisableThinkingFromChatOptions()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n\n await client.GetResponseAsync(\n [new ChatMessage(ChatRole.User, \"ping\")],\n new ChatOptions\n {\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"thinkingEnabled\"] = false\n }\n });\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.False(document.RootElement.GetProperty(\"enable_thinking\").GetBoolean());\n }\n\n [Fact]\n public async Task GetResponseAsync_IncludesStablePromptCacheKey()\n {\n var firstHandler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test-1\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var secondHandler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test-2\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var firstClient = CreateClient(firstHandler);\n var secondClient = CreateClient(secondHandler);\n\n await firstClient.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n await secondClient.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n\n using var firstDocument = JsonDocument.Parse(firstHandler.RequestBodies.Single());\n using var secondDocument = JsonDocument.Parse(secondHandler.RequestBodies.Single());\n var firstKey = firstDocument.RootElement.GetProperty(\"prompt_cache_key\").GetString();\n var secondKey = secondDocument.RootElement.GetProperty(\"prompt_cache_key\").GetString();\n\n Assert.False(string.IsNullOrWhiteSpace(firstKey));\n Assert.Equal(firstKey, secondKey);\n Assert.Equal(\"opendeepwiki\", firstKey);\n }\n\n [Fact]\n public async Task GetResponseAsync_UsesPromptCacheKeyFromRequestOptions()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(\n handler,\n new AiRequestOptions\n {\n SupportsThinking = true,\n PromptCacheKey = \"odw:content:repo\"\n });\n\n await client.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.Equal(\"odw:content:repo\", document.RootElement.GetProperty(\"prompt_cache_key\").GetString());\n }\n\n [Fact]\n public async Task GetResponseAsync_ChatOptionsPromptCacheKeyOverridesRequestOptions()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(\n handler,\n new AiRequestOptions\n {\n SupportsThinking = true,\n PromptCacheKey = \"request-key\"\n });\n\n await client.GetResponseAsync(\n [new ChatMessage(ChatRole.User, \"ping\")],\n new ChatOptions\n {\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"promptCacheKey\"] = \"chat-options-key\"\n }\n });\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.Equal(\"chat-options-key\", document.RootElement.GetProperty(\"prompt_cache_key\").GetString());\n }\n\n [Fact]\n public async Task GetResponseAsync_UsesSpecificToolChoice()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"saved\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n var writeDoc = AIFunctionFactory.Create(\n (string content) => $\"saved:{content}\",\n new AIFunctionFactoryOptions\n {\n Name = \"WriteDoc\",\n Description = \"Persist a Markdown document\"\n });\n\n await client.GetResponseAsync(\n [new ChatMessage(ChatRole.User, \"save\")],\n new ChatOptions\n {\n Tools = [writeDoc],\n ToolMode = ChatToolMode.RequireSpecific(\"WriteDoc\")\n });\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n var choice = document.RootElement.GetProperty(\"tool_choice\");\n Assert.Equal(\"function\", choice.GetProperty(\"type\").GetString());\n Assert.Equal(\"WriteDoc\", choice.GetProperty(\"function\").GetProperty(\"name\").GetString());\n }\n\n [Fact]\n public async Task GetResponseAsync_PrependsInstructionsAsSystemMessage()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n\n await client.GetResponseAsync(\n [new ChatMessage(ChatRole.User, \"ping\")],\n new ChatOptions\n {\n Instructions = \"You are a wiki assistant.\"\n });\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n var messages = document.RootElement.GetProperty(\"messages\").EnumerateArray().ToArray();\n Assert.Equal(\"system\", messages[0].GetProperty(\"role\").GetString());\n Assert.Equal(\"You are a wiki assistant.\", messages[0].GetProperty(\"content\").GetString());\n Assert.Equal(\"user\", messages[1].GetProperty(\"role\").GetString());\n }\n\n [Fact]\n public async Task GetStreamingResponseAsync_AcceptsDoneSentinelAndMapsUsage()\n {\n var handler = new StubHttpMessageHandler(_ => EventStreamResponse(\"\"\"\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"hel\"},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":2,\"total_tokens\":4}}\n\n data: DONE\n\n \"\"\"));\n var client = CreateClient(handler);\n var updates = new List<ChatResponseUpdate>();\n\n await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]))\n {\n updates.Add(update);\n }\n\n Assert.Equal(\"hello\", string.Concat(updates.Select(update => update.Text)));\n var usage = updates.SelectMany(update => update.Contents).OfType<UsageContent>().Single().Details;\n Assert.Equal(4, usage.TotalTokenCount);\n }\n\n [Fact]\n public async Task GetStreamingResponseAsync_AccumulatesToolCallDeltas()\n {\n var handler = new StubHttpMessageHandler(_ => EventStreamResponse(\"\"\"\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"\"}}]},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"README.md\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n data: [DONE]\n\n \"\"\"));\n var client = CreateClient(handler);\n var updates = new List<ChatResponseUpdate>();\n\n await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, \"read\")]))\n {\n updates.Add(update);\n }\n\n var functionCall = updates\n .SelectMany(update => update.Contents)\n .OfType<FunctionCallContent>()\n .Single();\n Assert.Equal(\"call_1\", functionCall.CallId);\n Assert.Equal(\"read_file\", functionCall.Name);\n Assert.NotNull(functionCall.Arguments);\n var arguments = functionCall.Arguments!;\n Assert.True(arguments.TryGetValue(\"path\", out var path));\n Assert.Equal(\"README.md\", path);\n }\n\n [Fact]\n public async Task GetStreamingResponseAsync_RoundTripsReasoningContentWithToolCalls()\n {\n var requestIndex = 0;\n var handler = new StubHttpMessageHandler(_ => requestIndex++ == 0\n ? EventStreamResponse(\"\"\"\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"reasoning_content\":\"think \"},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"reasoning_content\":\"more\"},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n data: [DONE]\n\n \"\"\")\n : JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test-2\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n var updates = new List<ChatResponseUpdate>();\n\n await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, \"read\")]))\n {\n updates.Add(update);\n }\n\n var reasoning = string.Concat(updates\n .SelectMany(update => update.Contents)\n .OfType<TextReasoningContent>()\n .Select(content => content.Text));\n Assert.Equal(\"think more\", reasoning);\n\n var conversation = new List<ChatMessage>\n {\n new(ChatRole.User, \"read\")\n };\n conversation.AddMessages(updates);\n conversation.Add(new ChatMessage(\n ChatRole.Tool,\n [new FunctionResultContent(\"call_1\", \"file contents\")]));\n\n await client.GetResponseAsync(conversation);\n\n using var document = JsonDocument.Parse(handler.RequestBodies[1]);\n var messages = document.RootElement.GetProperty(\"messages\").EnumerateArray().ToArray();\n var assistant = messages.Single(message =>\n message.GetProperty(\"role\").GetString() == \"assistant\");\n Assert.Equal(\"think more\", assistant.GetProperty(\"reasoning_content\").GetString());\n Assert.True(assistant.TryGetProperty(\"tool_calls\", out _));\n }\n\n private static DeepSeekOpenAIChatClient CreateClient(\n StubHttpMessageHandler handler,\n AiRequestOptions? options = null)\n {\n options ??= new AiRequestOptions\n {\n SupportsThinking = true,\n ThinkingConfigJson = \"\"\"\n {\n \"bodyParams\":{\"enable_thinking\":true},\n \"disabledBodyParams\":{\"enable_thinking\":false}\n }\n \"\"\"\n };\n\n return new DeepSeekOpenAIChatClient(\n \"deepseek-v4-flash\",\n \"https://api.deepseek.com/v1\",\n \"test-key\",\n new HttpClient(handler),\n options,\n disposeHttpClient: true);\n }\n\n private static HttpResponseMessage JsonResponse(string content)\n {\n return new HttpResponseMessage(HttpStatusCode.OK)\n {\n Content = new StringContent(content, Encoding.UTF8, \"application/json\")\n };\n }\n\n private static HttpResponseMessage EventStreamResponse(string content)\n {\n return new HttpResponseMessage(HttpStatusCode.OK)\n {\n Content = new StringContent(content, Encoding.UTF8, \"text/event-stream\")\n };\n }\n\n private sealed class StubHttpMessageHandler(\n Func<HttpRequestMessage, HttpResponseMessage> handle)\n : HttpMessageHandler\n {\n public List<string> RequestBodies { get; } = [];\n\n protected override async Task<HttpResponseMessage> SendAsync(\n HttpRequestMessage request,\n CancellationToken cancellationToken)\n {\n RequestBodies.Add(request.Content == null\n ? string.Empty\n : await request.Content.ReadAsStringAsync(cancellationToken));\n return handle(request);\n }\n }\n}\n"}, "1f5050c03db13081755e60b2ea722654fb95b84c": {"t": 1785760376.416263, "n": 13016, "h": "86b064d445b55fac", "c": "using System.Net;\nusing System.Text;\nusing System.Text.Json;\nusing Microsoft.Extensions.AI;\nusing OpenDeepWiki.Agents;\nusing Xunit;\n\nnamespace OpenDeepWiki.Tests.Agents;\n\npublic class DeepSeekOpenAIChatClientTests\n{\n [Fact]\n public async Task GetResponseAsync_AppliesThinkingBodyParamsByDefault()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\n \"id\":\"chatcmpl-test\",\n \"model\":\"deepseek-v4-flash\",\n \"created\":1700000000,\n \"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}],\n \"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3,\"prompt_tokens_details\":{\"cached_tokens\":1}}\n }\n \"\"\"));\n var client = CreateClient(handler);\n\n var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.True(document.RootElement.GetProperty(\"enable_thinking\").GetBoolean());\n Assert.Equal(\"pong\", response.Text);\n Assert.Equal(3, response.Usage?.TotalTokenCount);\n Assert.Equal(1, response.Usage?.CachedInputTokenCount);\n }\n\n [Fact]\n public async Task GetResponseAsync_CanDisableThinkingFromChatOptions()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n\n await client.GetResponseAsync(\n [new ChatMessage(ChatRole.User, \"ping\")],\n new ChatOptions\n {\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"thinkingEnabled\"] = false\n }\n });\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.False(document.RootElement.GetProperty(\"enable_thinking\").GetBoolean());\n }\n\n [Fact]\n public async Task GetResponseAsync_IncludesStablePromptCacheKey()\n {\n var firstHandler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test-1\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var secondHandler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test-2\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var firstClient = CreateClient(firstHandler);\n var secondClient = CreateClient(secondHandler);\n\n await firstClient.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n await secondClient.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n\n using var firstDocument = JsonDocument.Parse(firstHandler.RequestBodies.Single());\n using var secondDocument = JsonDocument.Parse(secondHandler.RequestBodies.Single());\n var firstKey = firstDocument.RootElement.GetProperty(\"prompt_cache_key\").GetString();\n var secondKey = secondDocument.RootElement.GetProperty(\"prompt_cache_key\").GetString();\n\n Assert.False(string.IsNullOrWhiteSpace(firstKey));\n Assert.Equal(firstKey, secondKey);\n Assert.Equal(\"opendeepwiki\", firstKey);\n }\n\n [Fact]\n public async Task GetResponseAsync_UsesPromptCacheKeyFromRequestOptions()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(\n handler,\n new AiRequestOptions\n {\n SupportsThinking = true,\n PromptCacheKey = \"odw:content:repo\"\n });\n\n await client.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.Equal(\"odw:content:repo\", document.RootElement.GetProperty(\"prompt_cache_key\").GetString());\n }\n\n [Fact]\n public async Task GetResponseAsync_ChatOptionsPromptCacheKeyOverridesRequestOptions()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(\n handler,\n new AiRequestOptions\n {\n SupportsThinking = true,\n PromptCacheKey = \"request-key\"\n });\n\n await client.GetResponseAsync(\n [new ChatMessage(ChatRole.User, \"ping\")],\n new ChatOptions\n {\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"promptCacheKey\"] = \"chat-options-key\"\n }\n });\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.Equal(\"chat-options-key\", document.RootElement.GetProperty(\"prompt_cache_key\").GetString());\n }\n\n [Fact]\n public async Task GetResponseAsync_PrependsInstructionsAsSystemMessage()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n\n await client.GetResponseAsync(\n [new ChatMessage(ChatRole.User, \"ping\")],\n new ChatOptions\n {\n Instructions = \"You are a wiki assistant.\"\n });\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n var messages = document.RootElement.GetProperty(\"messages\").EnumerateArray().ToArray();\n Assert.Equal(\"system\", messages[0].GetProperty(\"role\").GetString());\n Assert.Equal(\"You are a wiki assistant.\", messages[0].GetProperty(\"content\").GetString());\n Assert.Equal(\"user\", messages[1].GetProperty(\"role\").GetString());\n }\n\n [Fact]\n public async Task GetStreamingResponseAsync_AcceptsDoneSentinelAndMapsUsage()\n {\n var handler = new StubHttpMessageHandler(_ => EventStreamResponse(\"\"\"\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"hel\"},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":2,\"total_tokens\":4}}\n\n data: DONE\n\n \"\"\"));\n var client = CreateClient(handler);\n var updates = new List<ChatResponseUpdate>();\n\n await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]))\n {\n updates.Add(update);\n }\n\n Assert.Equal(\"hello\", string.Concat(updates.Select(update => update.Text)));\n var usage = updates.SelectMany(update => update.Contents).OfType<UsageContent>().Single().Details;\n Assert.Equal(4, usage.TotalTokenCount);\n }\n\n [Fact]\n public async Task GetStreamingResponseAsync_AccumulatesToolCallDeltas()\n {\n var handler = new StubHttpMessageHandler(_ => EventStreamResponse(\"\"\"\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"\"}}]},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"README.md\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n data: [DONE]\n\n \"\"\"));\n var client = CreateClient(handler);\n var updates = new List<ChatResponseUpdate>();\n\n await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, \"read\")]))\n {\n updates.Add(update);\n }\n\n var functionCall = updates\n .SelectMany(update => update.Contents)\n .OfType<FunctionCallContent>()\n .Single();\n Assert.Equal(\"call_1\", functionCall.CallId);\n Assert.Equal(\"read_file\", functionCall.Name);\n Assert.NotNull(functionCall.Arguments);\n var arguments = functionCall.Arguments!;\n Assert.True(arguments.TryGetValue(\"path\", out var path));\n Assert.Equal(\"README.md\", path);\n }\n\n [Fact]\n public async Task GetStreamingResponseAsync_RoundTripsReasoningContentWithToolCalls()\n {\n var requestIndex = 0;\n var handler = new StubHttpMessageHandler(_ => requestIndex++ == 0\n ? EventStreamResponse(\"\"\"\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"reasoning_content\":\"think \"},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"reasoning_content\":\"more\"},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n data: [DONE]\n\n \"\"\")\n : JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test-2\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n var updates = new List<ChatResponseUpdate>();\n\n await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, \"read\")]))\n {\n updates.Add(update);\n }\n\n var reasoning = string.Concat(updates\n .SelectMany(update => update.Contents)\n .OfType<TextReasoningContent>()\n .Select(content => content.Text));\n Assert.Equal(\"think more\", reasoning);\n\n var conversation = new List<ChatMessage>\n {\n new(ChatRole.User, \"read\")\n };\n conversation.AddMessages(updates);\n conversation.Add(new ChatMessage(\n ChatRole.Tool,\n [new FunctionResultContent(\"call_1\", \"file contents\")]));\n\n await client.GetResponseAsync(conversation);\n\n using var document = JsonDocument.Parse(handler.RequestBodies[1]);\n var messages = document.RootElement.GetProperty(\"messages\").EnumerateArray().ToArray();\n var assistant = messages.Single(message =>\n message.GetProperty(\"role\").GetString() == \"assistant\");\n Assert.Equal(\"think more\", assistant.GetProperty(\"reasoning_content\").GetString());\n Assert.True(assistant.TryGetProperty(\"tool_calls\", out _));\n }\n\n private static DeepSeekOpenAIChatClient CreateClient(\n StubHttpMessageHandler handler,\n AiRequestOptions? options = null)\n {\n options ??= new AiRequestOptions\n {\n SupportsThinking = true,\n ThinkingConfigJson = \"\"\"\n {\n \"bodyParams\":{\"enable_thinking\":true},\n \"disabledBodyParams\":{\"enable_thinking\":false}\n }\n \"\"\"\n };\n\n return new DeepSeekOpenAIChatClient(\n \"deepseek-v4-flash\",\n \"https://api.deepseek.com/v1\",\n \"test-key\",\n new HttpClient(handler),\n options,\n disposeHttpClient: true);\n }\n\n private static HttpResponseMessage JsonResponse(string content)\n {\n return new HttpResponseMessage(HttpStatusCode.OK)\n {\n Content = new StringContent(content, Encoding.UTF8, \"application/json\")\n };\n }\n\n private static HttpResponseMessage EventStreamResponse(string content)\n {\n return new HttpResponseMessage(HttpStatusCode.OK)\n {\n Content = new StringContent(content, Encoding.UTF8, \"text/event-stream\")\n };\n }\n\n private sealed class StubHttpMessageHandler(\n Func<HttpRequestMessage, HttpResponseMessage> handle)\n : HttpMessageHandler\n {\n public List<string> RequestBodies { get; } = [];\n\n protected override async Task<HttpResponseMessage> SendAsync(\n HttpRequestMessage request,\n CancellationToken cancellationToken)\n {\n RequestBodies.Add(request.Content == null\n ? string.Empty\n : await request.Content.ReadAsStringAsync(cancellationToken));\n return handle(request);\n }\n }\n}\n"}, "24e31a340b661bee848fad546832d68b7bcfa3d3": {"t": 1785760377.9802856, "n": 11028, "h": "d9fb5efc51d95f7f", "c": "using System.Net;\nusing System.Text;\nusing System.Text.Json;\nusing Microsoft.Extensions.AI;\nusing OpenDeepWiki.Agents;\nusing Xunit;\n\nnamespace OpenDeepWiki.Tests.Agents;\n\npublic class DeepSeekOpenAIChatClientTests\n{\n [Fact]\n public async Task GetResponseAsync_AppliesThinkingBodyParamsByDefault()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\n \"id\":\"chatcmpl-test\",\n \"model\":\"deepseek-v4-flash\",\n \"created\":1700000000,\n \"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}],\n \"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3,\"prompt_tokens_details\":{\"cached_tokens\":1}}\n }\n \"\"\"));\n var client = CreateClient(handler);\n\n var response = await client.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.True(document.RootElement.GetProperty(\"enable_thinking\").GetBoolean());\n Assert.Equal(\"pong\", response.Text);\n Assert.Equal(3, response.Usage?.TotalTokenCount);\n Assert.Equal(1, response.Usage?.CachedInputTokenCount);\n }\n\n [Fact]\n public async Task GetResponseAsync_CanDisableThinkingFromChatOptions()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n\n await client.GetResponseAsync(\n [new ChatMessage(ChatRole.User, \"ping\")],\n new ChatOptions\n {\n AdditionalProperties = new AdditionalPropertiesDictionary\n {\n [\"thinkingEnabled\"] = false\n }\n });\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n Assert.False(document.RootElement.GetProperty(\"enable_thinking\").GetBoolean());\n }\n\n [Fact]\n public async Task GetResponseAsync_IncludesStablePromptCacheKey()\n {\n var firstHandler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test-1\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var secondHandler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test-2\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var firstClient = CreateClient(firstHandler);\n var secondClient = CreateClient(secondHandler);\n\n await firstClient.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n await secondClient.GetResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]);\n\n using var firstDocument = JsonDocument.Parse(firstHandler.RequestBodies.Single());\n using var secondDocument = JsonDocument.Parse(secondHandler.RequestBodies.Single());\n var firstKey = firstDocument.RootElement.GetProperty(\"prompt_cache_key\").GetString();\n var secondKey = secondDocument.RootElement.GetProperty(\"prompt_cache_key\").GetString();\n\n Assert.False(string.IsNullOrWhiteSpace(firstKey));\n Assert.Equal(firstKey, secondKey);\n }\n\n [Fact]\n public async Task GetResponseAsync_PrependsInstructionsAsSystemMessage()\n {\n var handler = new StubHttpMessageHandler(_ => JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n\n await client.GetResponseAsync(\n [new ChatMessage(ChatRole.User, \"ping\")],\n new ChatOptions\n {\n Instructions = \"You are a wiki assistant.\"\n });\n\n using var document = JsonDocument.Parse(handler.RequestBodies.Single());\n var messages = document.RootElement.GetProperty(\"messages\").EnumerateArray().ToArray();\n Assert.Equal(\"system\", messages[0].GetProperty(\"role\").GetString());\n Assert.Equal(\"You are a wiki assistant.\", messages[0].GetProperty(\"content\").GetString());\n Assert.Equal(\"user\", messages[1].GetProperty(\"role\").GetString());\n }\n\n [Fact]\n public async Task GetStreamingResponseAsync_AcceptsDoneSentinelAndMapsUsage()\n {\n var handler = new StubHttpMessageHandler(_ => EventStreamResponse(\"\"\"\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"hel\"},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"lo\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":2,\"total_tokens\":4}}\n\n data: DONE\n\n \"\"\"));\n var client = CreateClient(handler);\n var updates = new List<ChatResponseUpdate>();\n\n await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, \"ping\")]))\n {\n updates.Add(update);\n }\n\n Assert.Equal(\"hello\", string.Concat(updates.Select(update => update.Text)));\n var usage = updates.SelectMany(update => update.Contents).OfType<UsageContent>().Single().Details;\n Assert.Equal(4, usage.TotalTokenCount);\n }\n\n [Fact]\n public async Task GetStreamingResponseAsync_AccumulatesToolCallDeltas()\n {\n var handler = new StubHttpMessageHandler(_ => EventStreamResponse(\"\"\"\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"\"}}]},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"README.md\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n data: [DONE]\n\n \"\"\"));\n var client = CreateClient(handler);\n var updates = new List<ChatResponseUpdate>();\n\n await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, \"read\")]))\n {\n updates.Add(update);\n }\n\n var functionCall = updates\n .SelectMany(update => update.Contents)\n .OfType<FunctionCallContent>()\n .Single();\n Assert.Equal(\"call_1\", functionCall.CallId);\n Assert.Equal(\"read_file\", functionCall.Name);\n Assert.NotNull(functionCall.Arguments);\n var arguments = functionCall.Arguments!;\n Assert.True(arguments.TryGetValue(\"path\", out var path));\n Assert.Equal(\"README.md\", path);\n }\n\n [Fact]\n public async Task GetStreamingResponseAsync_RoundTripsReasoningContentWithToolCalls()\n {\n var requestIndex = 0;\n var handler = new StubHttpMessageHandler(_ => requestIndex++ == 0\n ? EventStreamResponse(\"\"\"\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"reasoning_content\":\"think \"},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"reasoning_content\":\"more\"},\"finish_reason\":null}]}\n\n data: {\"id\":\"chatcmpl-test\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"README.md\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n data: [DONE]\n\n \"\"\")\n : JsonResponse(\"\"\"\n {\"id\":\"chatcmpl-test-2\",\"model\":\"deepseek-v4-pro\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"done\"},\"finish_reason\":\"stop\"}]}\n \"\"\"));\n var client = CreateClient(handler);\n var updates = new List<ChatResponseUpdate>();\n\n await foreach (var update in client.GetStreamingResponseAsync([new ChatMessage(ChatRole.User, \"read\")]))\n {\n updates.Add(update);\n }\n\n var reasoning = string.Concat(updates\n .SelectMany(update => update.Contents)\n .OfType<TextReasoningContent>()\n .Select(content => content.Text));\n Assert.Equal(\"think more\", reasoning);\n\n var conversation = new List<ChatMessage>\n {\n new(ChatRole.User, \"read\")\n };\n conversation.AddMessages(updates);\n conversation.Add(new ChatMessage(\n ChatRole.Tool,\n [new FunctionResultContent(\"call_1\", \"file contents\")]));\n\n await client.GetResponseAsync(conversation);\n\n using var document = JsonDocument.Parse(handler.RequestBodies[1]);\n var messages = document.RootElement.GetProperty(\"messages\").EnumerateArray().ToArray();\n var assistant = messages.Single(message =>\n message.GetProperty(\"role\").GetString() == \"assistant\");\n Assert.Equal(\"think more\", assistant.GetProperty(\"reasoning_content\").GetString());\n Assert.True(assistant.TryGetProperty(\"tool_calls\", out _));\n }\n\n private static DeepSeekOpenAIChatClient CreateClient(StubHttpMessageHandler handler)\n {\n return new DeepSeekOpenAIChatClient(\n \"deepseek-v4-flash\",\n \"https://api.deepseek.com/v1\",\n \"test-key\",\n new HttpClient(handler),\n new AiRequestOptions\n {\n SupportsThinking = true,\n ThinkingConfigJson = \"\"\"\n {\n \"bodyParams\":{\"enable_thinking\":true},\n \"disabledBodyParams\":{\"enable_thinking\":false}\n }\n \"\"\"\n },\n disposeHttpClient: true);\n }\n\n private static HttpResponseMessage JsonResponse(string content)\n {\n return new HttpResponseMessage(HttpStatusCode.OK)\n {\n Content = new StringContent(content, Encoding.UTF8, \"application/json\")\n };\n }\n\n private static HttpResponseMessage EventStreamResponse(string content)\n {\n return new HttpResponseMessage(HttpStatusCode.OK)\n {\n Content = new StringContent(content, Encoding.UTF8, \"text/event-stream\")\n };\n }\n\n private sealed class StubHttpMessageHandler(\n Func<HttpRequestMessage, HttpResponseMessage> handle)\n : HttpMessageHandler\n {\n public List<string> RequestBodies { get; } = [];\n\n protected override async Task<HttpResponseMessage> SendAsync(\n HttpRequestMessage request,\n CancellationToken cancellationToken)\n {\n RequestBodies.Add(request.Content == null\n ? string.Empty\n : await request.Content.ReadAsStringAsync(cancellationToken));\n return handle(request);\n }\n }\n}\n"}}}}