From db61f7590f5c6636e2a203e5a91df6fcbb0f8cac Mon Sep 17 00:00:00 2001 From: mcocdaa <2021137961@qq.com> Date: Sat, 7 Nov 2026 04:32:34 +0100 Subject: [PATCH] feat(model-service): open IModelProtocol registry, Core wire & secret redaction delegation, and CI test workflow --- .github/workflows/test.yml | 22 ++ Source/Client/EnvironmentEndpointOverlay.cs | 6 +- Source/Client/ModelServiceClient.cs | 92 ++--- Source/Client/ModelServiceClientFactory.cs | 30 +- Source/Client/OpenCodeGoClientFactory.cs | 14 +- Source/Diagnostics/EndpointHealthProbe.cs | 45 +-- Source/Models/ModelEndpointConfig.cs | 121 +++++- Source/Models/ModelEndpointPresets.cs | 42 +-- Source/Protocol/AnthropicProtocolAdapter.cs | 25 +- Source/Protocol/IModelProtocol.cs | 22 ++ Source/Protocol/ModelProtocolRegistry.cs | 348 ++++++++++++++++++ Source/Protocol/OpenAIProtocolAdapter.cs | 188 ++-------- Source/RimMindModelServiceMod.cs | 2 + Source/Security/SecretRedactor.cs | 26 ++ Source/Settings/ModelServiceSettingsDrawer.cs | 69 +++- Tests/Contracts/ModelServiceContracts.cs | 261 +++++++++++++ .../OpenAIProtocolParityContracts.cs | 302 +++++++++++++++ Tests/RimMindModelService.Tests.csproj | 11 + Tests/VerseStubs.cs | 62 +++- 19 files changed, 1370 insertions(+), 318 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 Source/Protocol/IModelProtocol.cs create mode 100644 Source/Protocol/ModelProtocolRegistry.cs create mode 100644 Source/Security/SecretRedactor.cs create mode 100644 Tests/Contracts/OpenAIProtocolParityContracts.cs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fb71105 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Unit Tests + +on: + push: + branches: [main, master] + paths: + - 'Source/**' + - 'Tests/**' + - 'ArchTests/**' + - '.github/workflows/test.yml' + pull_request: + branches: [main, master] + paths: + - 'Source/**' + - 'Tests/**' + - 'ArchTests/**' + - '.github/workflows/test.yml' + +jobs: + test: + uses: RimWorld-RimMind-Mod/Workflows/.github/workflows/test.yml@main + secrets: inherit diff --git a/Source/Client/EnvironmentEndpointOverlay.cs b/Source/Client/EnvironmentEndpointOverlay.cs index be72db8..6f50a22 100644 --- a/Source/Client/EnvironmentEndpointOverlay.cs +++ b/Source/Client/EnvironmentEndpointOverlay.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using RimMind.ModelService.Models; +using RimMind.ModelService.Protocol; namespace RimMind.ModelService.Client { @@ -64,7 +65,8 @@ public IReadOnlyList Apply(IReadOnlyList Apply(IReadOnlyList DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); } - private static string SanitizeAndTruncate(string? text, int maxChars = MaxErrorBodyChars) + // Redaction patterns are shared with Core's LogSanitizer via SecretRedactor (parity-tested); + // the node's own configured key is masked literally as well, whatever its shape. + private static string SanitizeAndTruncate(string? text, string? knownSecret, int maxChars = MaxErrorBodyChars) { if (string.IsNullOrEmpty(text)) return string.Empty; - string redacted = SecretRedactionRegex.Replace(text!, "***"); - if (maxChars > 0 && redacted.Length > maxChars) - return redacted.Substring(0, maxChars) + $"…[truncated {redacted.Length - maxChars} chars]"; - return redacted; + return SecretRedactor.ForMessageWithSecret(text, knownSecret, maxChars); } private static bool IsTransientError(RimMindError error) => @@ -174,7 +168,8 @@ private async Task> SendCoreAsync( triedEndpoints.Add(node.id); - if (node.providerType == ProviderType.LocalSubscriptionGateway && !node.IsLoopbackAddress) + var protocol = ModelProtocolRegistry.Resolve(node); + if (protocol.LegacyProviderType == ProviderType.LocalSubscriptionGateway && !node.IsLoopbackAddress) { long failTick = _clockMs(); node.RecordFailure(failTick); @@ -184,7 +179,7 @@ private async Task> SendCoreAsync( continue; } - var result = await TrySendToNodeAsync(envelope, node, overallToken, callerToken).ConfigureAwait(false); + var result = await TrySendToNodeAsync(envelope, node, protocol, overallToken, callerToken).ConfigureAwait(false); if (result.IsOk) { return result; @@ -233,6 +228,7 @@ private async Task> SendCoreAsync( private async Task> TrySendToNodeAsync( LlmRequestEnvelope envelope, ModelEndpointConfig node, + IModelProtocol protocol, CancellationToken overallToken, CancellationToken callerToken) { @@ -242,54 +238,19 @@ private async Task> TrySendToNodeAsync( var stopwatch = Stopwatch.StartNew(); try { - string requestUrl; - string payloadJson; - using var request = new HttpRequestMessage(HttpMethod.Post, ""); - - if (node.providerType == ProviderType.AnthropicClaude) + string modelOverride = !string.IsNullOrEmpty(node.modelName) ? node.modelName : protocol.DefaultModel; + var payloadResult = protocol.BuildRequestJson(envelope, modelOverride); + if (payloadResult.IsErr) { - requestUrl = node.endpoint.TrimEnd('/') + "/messages"; - payloadJson = AnthropicProtocolAdapter.BuildRequestJson(envelope, node); - - request.RequestUri = new Uri(requestUrl); - request.Headers.TryAddWithoutValidation("x-api-key", node.apiKey); - request.Headers.TryAddWithoutValidation("anthropic-version", "2023-06-01"); + return Result.Err(payloadResult.Error); } - else if (node.providerType == ProviderType.OpenCodeGo) - { - requestUrl = node.endpoint.TrimEnd('/') + "/chat/completions"; - payloadJson = OpenAIProtocolAdapter.BuildRequestJson(envelope, node); - request.RequestUri = new Uri(requestUrl); - if (!string.IsNullOrEmpty(node.apiKey)) - { - request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + node.apiKey); - } - string sessionGuid = !string.IsNullOrEmpty(envelope.NpcId) - ? envelope.NpcId! - : (!string.IsNullOrEmpty(envelope.RequestId) - ? envelope.RequestId! - : Guid.NewGuid().ToString("N").Substring(0, 16)); - request.Headers.TryAddWithoutValidation("x-opencode-session", "rimmind-" + sessionGuid); - } - else // OpenAICompatible or LocalSubscriptionGateway - { - requestUrl = node.endpoint.TrimEnd('/') + "/chat/completions"; - payloadJson = OpenAIProtocolAdapter.BuildRequestJson(envelope, node); + string defaultRequestUrl = node.endpoint.TrimEnd('/') + "/chat/completions"; + using var request = new HttpRequestMessage(HttpMethod.Post, defaultRequestUrl); + string? sessionKey = !string.IsNullOrEmpty(envelope.NpcId) ? envelope.NpcId : envelope.RequestId; + protocol.ApplyHeaders(request, node, sessionKey); - request.RequestUri = new Uri(requestUrl); - if (!string.IsNullOrEmpty(node.apiKey)) - { - request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + node.apiKey); - } - if (!string.IsNullOrEmpty(requestUrl) && (requestUrl.IndexOf("opencode", StringComparison.OrdinalIgnoreCase) >= 0 || (!string.IsNullOrEmpty(node.apiKey) && node.apiKey.IndexOf("oc_sk_", StringComparison.OrdinalIgnoreCase) >= 0))) - { - string fallbackSession = !string.IsNullOrEmpty(envelope.NpcId) ? envelope.NpcId! : Guid.NewGuid().ToString("N").Substring(0, 12); - request.Headers.TryAddWithoutValidation("x-opencode-session", "rimmind-" + fallbackSession); - } - } - - using var content = new StringContent(payloadJson, Encoding.UTF8, "application/json"); + using var content = new StringContent(payloadResult.Value, Encoding.UTF8, "application/json"); request.Content = content; using var response = await _httpSend(request, linkedCts.Token).ConfigureAwait(false); @@ -299,7 +260,7 @@ private async Task> TrySendToNodeAsync( if (!response.IsSuccessStatusCode) { int statusCode = (int)response.StatusCode; - string safeBody = SanitizeAndTruncate(responseBody); + string safeBody = SanitizeAndTruncate(responseBody, node.apiKey); string errMsg = $"Node '{node.name}' HTTP {statusCode}: {safeBody}"; return Result.Err( (statusCode == 429 || statusCode >= 500 || statusCode == 408) @@ -307,21 +268,14 @@ private async Task> TrySendToNodeAsync( : RimMindErrors.ClientPermanent(errMsg)); } - Result parseResult; - if (node.providerType == ProviderType.AnthropicClaude) - { - parseResult = AnthropicProtocolAdapter.ParseResponse(responseBody); - } - else - { - parseResult = OpenAIProtocolAdapter.ParseResponse(responseBody); - } - + var parseResult = protocol.ParseResponse(responseBody, envelope); if (parseResult.IsOk) { node.RecordSuccess((int)stopwatch.ElapsedMilliseconds); var val = parseResult.Value; - string reqId = string.IsNullOrEmpty(val.RequestId) ? envelope.RequestId : val.RequestId; + // Like Core's OpenAIClient, responses are correlated by the envelope's RequestId; + // the provider's own completion id is only a fallback for id-less envelopes. + string reqId = !string.IsNullOrEmpty(envelope.RequestId) ? envelope.RequestId : val.RequestId; val = val.With(requestId: reqId, state: AIRequestState.Completed); parseResult = Result.Ok(val); } @@ -342,7 +296,7 @@ private async Task> TrySendToNodeAsync( { stopwatch.Stop(); return Result.Err( - RimMindErrors.ClientTransient($"Failed to reach node '{node.name}': {SanitizeAndTruncate(ex.Message)}", ex)); + RimMindErrors.ClientTransient($"Failed to reach node '{node.name}': {SanitizeAndTruncate(ex.Message, node.apiKey)}", ex)); } } diff --git a/Source/Client/ModelServiceClientFactory.cs b/Source/Client/ModelServiceClientFactory.cs index 821882e..b43e937 100644 --- a/Source/Client/ModelServiceClientFactory.cs +++ b/Source/Client/ModelServiceClientFactory.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using RimMind.Application.Common.Helpers; using RimMind.Application.Common.Interfaces.Client; using RimMind.Application.Common.Interfaces.Internal; using RimMind.ModelService.Models; @@ -10,14 +11,37 @@ namespace RimMind.ModelService.Client public class ModelServiceClientFactory : IAIClientFactory { public const string ModelServiceProviderId = "extended_service"; + public const string ModelServiceSettingsTabId = "model_service"; + public const string ModelServiceDisplayLabelKey = "RimMind.ModelService.Provider.ExtendedService"; + public const string ModelServiceOwnerModId = "mcocdaa.RimMindModelService"; + + static ModelServiceClientFactory() + { + RegisterDescriptor(); + } + + public ModelServiceClientFactory() + { + RegisterDescriptor(); + } + + public static void RegisterDescriptor() + { + AIProviderRegistry.Register(new AIProviderDescriptor( + ModelServiceProviderId, + RequiresApiKey: false, + SettingsTabId: ModelServiceSettingsTabId, + DisplayLabelKey: ModelServiceDisplayLabelKey, + OwnerModId: ModelServiceOwnerModId)); + } public string Id => ModelServiceProviderId; - public string OwnerModId => "mcocdaa.RimMindModelService"; + public string OwnerModId => ModelServiceOwnerModId; public string ProviderId => ModelServiceProviderId; public bool RequiresApiKey => false; - public string DisplayLabel => global::Verse.Translator.CanTranslate("RimMind.ModelService.Provider.ExtendedService") - ? (string)global::Verse.Translator.Translate("RimMind.ModelService.Provider.ExtendedService") + public string DisplayLabel => global::Verse.Translator.CanTranslate(ModelServiceDisplayLabelKey) + ? (string)global::Verse.Translator.Translate(ModelServiceDisplayLabelKey) : "Extended Model Service (Load Balanced)"; public string? DefaultEndpoint => null; public string? DefaultModelName => null; diff --git a/Source/Client/OpenCodeGoClientFactory.cs b/Source/Client/OpenCodeGoClientFactory.cs index b62bb04..4c292e2 100644 --- a/Source/Client/OpenCodeGoClientFactory.cs +++ b/Source/Client/OpenCodeGoClientFactory.cs @@ -2,6 +2,7 @@ using RimMind.Application.Common.Interfaces.Client; using RimMind.Application.Common.Interfaces.Internal; using RimMind.ModelService.Models; +using RimMind.ModelService.Protocol; using RimMind.ModelService.Settings; namespace RimMind.ModelService.Client @@ -18,19 +19,20 @@ public class OpenCodeGoClientFactory : IAIClientFactory public string DisplayLabel => global::Verse.Translator.CanTranslate("RimMind.ModelService.Provider.OpenCodeGo") ? (string)global::Verse.Translator.Translate("RimMind.ModelService.Provider.OpenCodeGo") : "OpenCode Go"; - public string? DefaultEndpoint => "https://opencode.ai/zen/go/v1"; - public string? DefaultModelName => "deepseek-v4.1-flash"; + public string? DefaultEndpoint => OpenCodeGoModelProtocol.Instance.DefaultEndpointUrl; + public string? DefaultModelName => OpenCodeGoModelProtocol.Instance.DefaultModel; public int OrderWeight => 40; public bool VisibleInMenu => true; public IAIClient Create(ISettingsProvider settings) { + var protocol = ModelProtocolRegistry.Resolve(ModelProtocolRegistry.OpenCodeGoProtocolId); string endpointUrl = !string.IsNullOrWhiteSpace(settings?.ApiEndpoint) ? settings!.ApiEndpoint - : "https://opencode.ai/zen/go/v1"; + : protocol.DefaultEndpointUrl; string model = !string.IsNullOrWhiteSpace(settings?.ModelName) ? settings!.ModelName - : "deepseek-v4.1-flash"; + : protocol.DefaultModel; string apiKey = settings?.ApiKey ?? string.Empty; var node = new ModelEndpointConfig @@ -40,7 +42,8 @@ public IAIClient Create(ISettingsProvider settings) endpoint = endpointUrl, modelName = model, apiKey = apiKey, - providerType = ProviderType.OpenCodeGo, + providerType = protocol.LegacyProviderType, + protocolId = protocol.ProtocolId, isEnabled = !string.IsNullOrWhiteSpace(apiKey), priority = 0, weight = 1 @@ -53,4 +56,3 @@ public IAIClient Create(ISettingsProvider settings) } } } - diff --git a/Source/Diagnostics/EndpointHealthProbe.cs b/Source/Diagnostics/EndpointHealthProbe.cs index c114626..85029a7 100644 --- a/Source/Diagnostics/EndpointHealthProbe.cs +++ b/Source/Diagnostics/EndpointHealthProbe.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using RimMind.ModelService.Models; +using RimMind.ModelService.Protocol; using RimMind.ModelService.Security; namespace RimMind.ModelService.Diagnostics @@ -18,17 +19,25 @@ public static class EndpointHealthProbe Timeout = TimeSpan.FromSeconds(15) }; - public static async Task<(bool success, int latencyMs, string message)> ProbeEndpointAsync( + public static Task<(bool success, int latencyMs, string message)> ProbeEndpointAsync( ModelEndpointConfig endpoint, CancellationToken ct = default) + => ProbeEndpointAsync(endpoint, ct, null); + + internal static async Task<(bool success, int latencyMs, string message)> ProbeEndpointAsync( + ModelEndpointConfig endpoint, + CancellationToken ct, + Func>? httpSendOverride) { if (endpoint == null || string.IsNullOrWhiteSpace(endpoint.endpoint)) { return (false, -1, "Invalid or empty endpoint URL"); } + var protocol = ModelProtocolRegistry.Resolve(endpoint); + // Security check for local subscription gateway - if (endpoint.providerType == ProviderType.LocalSubscriptionGateway) + if (protocol.LegacyProviderType == ProviderType.LocalSubscriptionGateway) { if (!LocalLoopbackValidator.IsLoopbackAddress(endpoint.endpoint)) { @@ -36,30 +45,15 @@ public static class EndpointHealthProbe } } + var sendAsync = httpSendOverride + ?? ((req, token) => HttpClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, token)); + var stopwatch = Stopwatch.StartNew(); try { - using var request = new HttpRequestMessage(HttpMethod.Get, endpoint.endpoint.TrimEnd('/') + "/models"); - if (!string.IsNullOrEmpty(endpoint.apiKey)) - { - if (endpoint.providerType == ProviderType.AnthropicClaude) - { - request.Headers.TryAddWithoutValidation("x-api-key", endpoint.apiKey); - request.Headers.TryAddWithoutValidation("anthropic-version", "2023-06-01"); - } - else - { - request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + endpoint.apiKey); - } - } - if (endpoint.providerType == ProviderType.OpenCodeGo - || (!string.IsNullOrEmpty(endpoint.endpoint) && endpoint.endpoint.IndexOf("opencode", StringComparison.OrdinalIgnoreCase) >= 0) - || (!string.IsNullOrEmpty(endpoint.apiKey) && endpoint.apiKey.StartsWith("oc_sk_", StringComparison.OrdinalIgnoreCase))) - { - request.Headers.TryAddWithoutValidation("x-opencode-session", "rimmind-health-probe"); - } - - using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + string resolvedUrl = endpoint.endpoint.TrimEnd('/') + "/models"; + using var request = protocol.BuildHealthProbeRequest(endpoint, resolvedUrl); + using var response = await sendAsync(request, ct).ConfigureAwait(false); stopwatch.Stop(); int latency = (int)stopwatch.ElapsedMilliseconds; @@ -84,7 +78,7 @@ public static class EndpointHealthProbe { endpoint.lastLatencyMs = latency; endpoint.isHealthy = false; - return (false, latency, $"HTTP {statusCode} {response.ReasonPhrase}"); + return (false, latency, $"HTTP {statusCode} {SecretRedactor.ForMessageWithSecret(response.ReasonPhrase, endpoint.apiKey)}"); } } catch (Exception ex) @@ -92,7 +86,8 @@ public static class EndpointHealthProbe stopwatch.Stop(); endpoint.lastLatencyMs = -1; endpoint.isHealthy = false; - return (false, -1, ex.Message); + // The probe message is shown in the settings UI / debug log: never echo raw exception text. + return (false, -1, SecretRedactor.ForMessageWithSecret(ex.Message, endpoint.apiKey)); } } } diff --git a/Source/Models/ModelEndpointConfig.cs b/Source/Models/ModelEndpointConfig.cs index 16113cd..7d1b041 100644 --- a/Source/Models/ModelEndpointConfig.cs +++ b/Source/Models/ModelEndpointConfig.cs @@ -1,5 +1,6 @@ using System; using System.Threading; +using RimMind.ModelService.Protocol; using Verse; namespace RimMind.ModelService.Models @@ -8,7 +9,78 @@ public class ModelEndpointConfig : IExposable { public string id = Guid.NewGuid().ToString("N"); public string name = "Default Node"; - public ProviderType providerType = ProviderType.OpenAICompatible; + + private ProviderType _providerType = ProviderType.OpenAICompatible; + private string _protocolId = ""; + + public ProviderType providerType + { + get => _providerType; + set + { + _providerType = value; + if (string.IsNullOrWhiteSpace(_protocolId) || ModelProtocolRegistry.TryGetBuiltInProviderType(_protocolId, out _)) + { + _protocolId = ModelProtocolRegistry.ToProtocolId(value); + } + else if (ModelProtocolRegistry.TryGet(_protocolId, out var customProto) && customProto.LegacyProviderType != value) + { + _protocolId = ModelProtocolRegistry.ToProtocolId(value); + } + else if (value != ProviderType.OpenAICompatible) + { + _protocolId = ModelProtocolRegistry.ToProtocolId(value); + } + } + } + + public string protocolId + { + get => _protocolId; + set + { + if (string.IsNullOrWhiteSpace(value)) + { + _protocolId = string.Empty; + return; + } + + string trimmed = value.Trim(); + if (ModelProtocolRegistry.TryGetBuiltInProviderType(trimmed, out var builtInType)) + { + _providerType = builtInType; + _protocolId = ModelProtocolRegistry.ToProtocolId(builtInType); + } + else + { + _protocolId = trimmed; + if (ModelProtocolRegistry.TryGet(trimmed, out var customProto)) + { + _providerType = customProto.LegacyProviderType; + } + } + } + } + + public string EffectiveProtocolId + { + get + { + if (!string.IsNullOrWhiteSpace(_protocolId)) + { + string trimmed = _protocolId.Trim(); + if (ModelProtocolRegistry.TryGetBuiltInProviderType(trimmed, out var builtInType)) + { + return builtInType == _providerType + ? ModelProtocolRegistry.ToProtocolId(builtInType) + : ModelProtocolRegistry.ToProtocolId(_providerType); + } + return trimmed; + } + return ModelProtocolRegistry.ToProtocolId(_providerType); + } + } + public string endpoint = "https://api.openai.com/v1"; public string apiKey = ""; public string modelName = "gpt-4o-mini"; @@ -84,11 +156,54 @@ public void RecordFailure(long currentTick, long cooldownTicks = 60000) } } + public void SynchronizeProtocol() + { + if (string.IsNullOrWhiteSpace(_protocolId)) + { + _protocolId = ModelProtocolRegistry.ToProtocolId(_providerType); + return; + } + + string trimmed = _protocolId.Trim(); + if (ModelProtocolRegistry.TryGetBuiltInProviderType(trimmed, out var builtInType)) + { + if (builtInType != _providerType) + { + if (_providerType != ProviderType.OpenAICompatible && builtInType == ProviderType.OpenAICompatible) + { + _protocolId = ModelProtocolRegistry.ToProtocolId(_providerType); + } + else + { + _providerType = builtInType; + _protocolId = ModelProtocolRegistry.ToProtocolId(builtInType); + } + } + else + { + _protocolId = ModelProtocolRegistry.ToProtocolId(builtInType); + } + return; + } + + _protocolId = trimmed; + if (ModelProtocolRegistry.TryGet(trimmed, out var customProto)) + { + _providerType = customProto.LegacyProviderType; + } + } + public void ExposeData() { + if (Scribe.mode == LoadSaveMode.Saving) + { + SynchronizeProtocol(); + } + Scribe_Values.Look(ref id, "id", Guid.NewGuid().ToString("N")); Scribe_Values.Look(ref name, "name", "Default Node"); - Scribe_Values.Look(ref providerType, "providerType", ProviderType.OpenAICompatible); + Scribe_Values.Look(ref _providerType, "providerType", ProviderType.OpenAICompatible); + Scribe_Values.Look(ref _protocolId, "protocolId", ""); Scribe_Values.Look(ref endpoint, "endpoint", "https://api.openai.com/v1"); Scribe_Values.Look(ref apiKey, "apiKey", ""); Scribe_Values.Look(ref modelName, "modelName", "gpt-4o-mini"); @@ -96,6 +211,8 @@ public void ExposeData() Scribe_Values.Look(ref weight, "weight", 1); Scribe_Values.Look(ref timeoutSeconds, "timeoutSeconds", 60); Scribe_Values.Look(ref isEnabled, "isEnabled", true); + + SynchronizeProtocol(); } public static ModelEndpointConfig CreateCodexGatewayPreset(int priority = 0) => diff --git a/Source/Models/ModelEndpointPresets.cs b/Source/Models/ModelEndpointPresets.cs index f703e73..3489047 100644 --- a/Source/Models/ModelEndpointPresets.cs +++ b/Source/Models/ModelEndpointPresets.cs @@ -1,31 +1,31 @@ using System; +using RimMind.ModelService.Protocol; namespace RimMind.ModelService.Models { public static class ModelEndpointPresets { - public static ModelEndpointConfig CreateCodexGatewayPreset(int priority = 0) => new ModelEndpointConfig + public static ModelEndpointConfig CreatePreset(string protocolId, string? displayName = null, int priority = 0) { - id = Guid.NewGuid().ToString("N"), - name = "Codex Local Gateway (sub2api)", - endpoint = "http://127.0.0.1:8000/v1", - modelName = "gpt-4o", - providerType = ProviderType.LocalSubscriptionGateway, - isEnabled = true, - priority = priority, - weight = 1 - }; + var protocol = ModelProtocolRegistry.Resolve(protocolId); + return new ModelEndpointConfig + { + id = Guid.NewGuid().ToString("N"), + name = !string.IsNullOrWhiteSpace(displayName) ? displayName! : protocol.FallbackLabel, + endpoint = protocol.DefaultEndpointUrl, + modelName = protocol.DefaultModel, + providerType = protocol.LegacyProviderType, + protocolId = protocol.ProtocolId, + isEnabled = true, + priority = priority, + weight = 1 + }; + } - public static ModelEndpointConfig CreateOpenCodeGoPreset(int priority = 1) => new ModelEndpointConfig - { - id = Guid.NewGuid().ToString("N"), - name = "OpenCode Go (Direct)", - endpoint = "https://opencode.ai/zen/go/v1", - modelName = "deepseek-v4.1-flash", - providerType = ProviderType.OpenCodeGo, - isEnabled = true, - priority = priority, - weight = 1 - }; + public static ModelEndpointConfig CreateCodexGatewayPreset(int priority = 0) => + CreatePreset(ModelProtocolRegistry.LocalGatewayProtocolId, "Codex Local Gateway (sub2api)", priority); + + public static ModelEndpointConfig CreateOpenCodeGoPreset(int priority = 1) => + CreatePreset(ModelProtocolRegistry.OpenCodeGoProtocolId, "OpenCode Go (Direct)", priority); } } diff --git a/Source/Protocol/AnthropicProtocolAdapter.cs b/Source/Protocol/AnthropicProtocolAdapter.cs index 9afc42b..cba47a8 100644 --- a/Source/Protocol/AnthropicProtocolAdapter.cs +++ b/Source/Protocol/AnthropicProtocolAdapter.cs @@ -8,6 +8,7 @@ using RimMind.Domain.Llm; using RimMind.Domain.ValueObjects; using RimMind.ModelService.Models; +using RimMind.ModelService.Security; namespace RimMind.ModelService.Protocol { @@ -47,8 +48,14 @@ public static string BuildRequestJson(LlmRequestEnvelope envelope, ModelEndpoint var messagesArray = new JArray(); if (envelope.Messages != null && envelope.Messages.Count > 0) { - var nonSystemMessages = envelope.Messages.Where(m => m.Role != "system").ToList(); - var lastUserMsgRef = nonSystemMessages.LastOrDefault(m => m.Role == "user"); + var liveMessages = envelope.Messages.Where(m => m != null && m.Role != "system").ToList(); + var lastUserMsgRef = liveMessages.LastOrDefault(m => m.Role == "user"); + + // Few-shot examples precede the live conversation (same placement as Core's OpenAI serializer). + var nonSystemMessages = new List(); + if (envelope.Examples != null) + nonSystemMessages.AddRange(envelope.Examples.Where(ex => ex != null && ex.Role != "system")); + nonSystemMessages.AddRange(liveMessages); JObject? lastUserMsg = null; foreach (var msg in nonSystemMessages) @@ -152,7 +159,9 @@ public static string BuildRequestJson(LlmRequestEnvelope envelope, ModelEndpoint var tool = sortedTools[i]; var toolObj = new JObject { - ["name"] = tool.Name, + // Anthropic tool names must match ^[a-zA-Z0-9_-]{1,64}$; Core's ToolRegistry + // resolves the underscore form back to the dotted tool id. + ["name"] = tool.Name?.Replace('.', '_') ?? "", ["description"] = tool.Description ?? "" }; @@ -181,6 +190,10 @@ public static string BuildRequestJson(LlmRequestEnvelope envelope, ModelEndpoint toolsArray.Add(toolObj); } root["tools"] = toolsArray; + + // Mirror Core's tool_choice=required semantics ("any" = must call at least one tool). + if (envelope.Tools.Any(t => t.ToolChoice == "required")) + root["tool_choice"] = new JObject { ["type"] = "any" }; } return root.ToString(Formatting.None); @@ -198,13 +211,13 @@ public static Result ParseResponse(string responseJso if (token["type"]?.ToString() == "error" || token["error"] != null) { string msg = token["error"]?["message"]?.ToString() ?? "Unknown Anthropic error"; - return Result.Err(RimMindErrors.ClientPermanent(msg)); + return Result.Err(RimMindErrors.ClientPermanent(SecretRedactor.ForMessage(msg))); } var contentArray = token["content"] as JArray; var textBuilder = new StringBuilder(); var reasoningBuilder = new StringBuilder(); - string toolCallsJson = ""; + string? toolCallsJson = null; if (contentArray != null) { @@ -264,7 +277,7 @@ public static Result ParseResponse(string responseJso catch (Exception ex) { return Result.Err( - RimMindErrors.ClientPermanent($"Failed to parse Anthropic response: {ex.Message}")); + RimMindErrors.ClientPermanent($"Failed to parse Anthropic response: {SecretRedactor.ForMessage(ex.Message)}")); } } } diff --git a/Source/Protocol/IModelProtocol.cs b/Source/Protocol/IModelProtocol.cs new file mode 100644 index 0000000..72ac8ef --- /dev/null +++ b/Source/Protocol/IModelProtocol.cs @@ -0,0 +1,22 @@ +using System.Net.Http; +using RimMind.Domain.Common; +using RimMind.Domain.Llm; +using RimMind.Domain.ValueObjects; +using RimMind.ModelService.Models; + +namespace RimMind.ModelService.Protocol +{ + public interface IModelProtocol + { + string ProtocolId { get; } + ProviderType LegacyProviderType { get; } + string DisplayLabelKey { get; } + string FallbackLabel { get; } + string DefaultEndpointUrl { get; } + string DefaultModel { get; } + Result BuildRequestJson(LlmRequestEnvelope envelope, string modelOverride); + Result ParseResponse(string responseBody, LlmRequestEnvelope? envelope = null); + void ApplyHeaders(HttpRequestMessage request, ModelEndpointConfig endpoint, string? requestId); + HttpRequestMessage BuildHealthProbeRequest(ModelEndpointConfig endpoint, string resolvedUrl); + } +} diff --git a/Source/Protocol/ModelProtocolRegistry.cs b/Source/Protocol/ModelProtocolRegistry.cs new file mode 100644 index 0000000..0c57e00 --- /dev/null +++ b/Source/Protocol/ModelProtocolRegistry.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using RimMind.Domain.Common; +using RimMind.Domain.Llm; +using RimMind.Domain.ValueObjects; +using RimMind.ModelService.Models; + +namespace RimMind.ModelService.Protocol +{ + public static class ModelProtocolRegistry + { + public const string OpenAIProtocolId = "openai"; + public const string AnthropicProtocolId = "anthropic"; + public const string LocalGatewayProtocolId = "local_gateway"; + public const string OpenCodeGoProtocolId = "opencode_go"; + + private static readonly object SyncRoot = new object(); + private static readonly List OrderedProtocols = new List(); + private static readonly Dictionary ProtocolsById = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + static ModelProtocolRegistry() + { + ResetToDefaults(); + } + + public static IReadOnlyList All + { + get + { + lock (SyncRoot) + { + return OrderedProtocols.ToArray(); + } + } + } + + public static void ResetToDefaults() + { + lock (SyncRoot) + { + OrderedProtocols.Clear(); + ProtocolsById.Clear(); + + RegisterCore(OpenAIModelProtocol.Instance); + RegisterCore(AnthropicModelProtocol.Instance); + RegisterCore(LocalGatewayModelProtocol.Instance); + RegisterCore(OpenCodeGoModelProtocol.Instance); + } + } + + public static void Register(IModelProtocol protocol) + { + if (protocol == null) throw new ArgumentNullException(nameof(protocol)); + if (string.IsNullOrWhiteSpace(protocol.ProtocolId)) + throw new ArgumentException("ProtocolId cannot be null or whitespace.", nameof(protocol)); + + lock (SyncRoot) + { + RegisterCore(protocol); + } + } + + public static bool Unregister(string protocolId) + { + if (string.IsNullOrWhiteSpace(protocolId)) return false; + + string key = protocolId.Trim(); + lock (SyncRoot) + { + if (!ProtocolsById.TryGetValue(key, out var existing)) + return false; + + ProtocolsById.Remove(key); + OrderedProtocols.RemoveAll(p => string.Equals(p.ProtocolId, existing.ProtocolId, StringComparison.OrdinalIgnoreCase)); + return true; + } + } + + public static bool TryGet(string? protocolId, out IModelProtocol protocol) + { + protocol = null!; + if (string.IsNullOrWhiteSpace(protocolId)) return false; + + string key = NormalizeAlias(protocolId!.Trim()); + lock (SyncRoot) + { + return ProtocolsById.TryGetValue(key, out protocol!); + } + } + + public static IModelProtocol? FindById(string? protocolId) => + TryGet(protocolId, out var protocol) ? protocol : null; + + public static IModelProtocol Resolve(ModelEndpointConfig? endpoint) + { + if (endpoint == null) + return OpenAIModelProtocol.Instance; + + return Resolve(endpoint.EffectiveProtocolId, endpoint.providerType); + } + + public static IModelProtocol Resolve(string? protocolId, ProviderType fallbackProviderType = ProviderType.OpenAICompatible) + { + if (TryGet(protocolId, out var protocol)) + return protocol; + + string fallbackId = ToProtocolId(fallbackProviderType); + if (TryGet(fallbackId, out var fallbackProtocol)) + return fallbackProtocol; + + return OpenAIModelProtocol.Instance; + } + + public static string ToProtocolId(ProviderType providerType) => providerType switch + { + ProviderType.OpenAICompatible => OpenAIProtocolId, + ProviderType.AnthropicClaude => AnthropicProtocolId, + ProviderType.LocalSubscriptionGateway => LocalGatewayProtocolId, + ProviderType.OpenCodeGo => OpenCodeGoProtocolId, + _ => OpenAIProtocolId + }; + + public static bool TryGetBuiltInProviderType(string? protocolId, out ProviderType providerType) + { + providerType = ProviderType.OpenAICompatible; + if (string.IsNullOrWhiteSpace(protocolId)) + return false; + + string normalized = NormalizeAlias(protocolId!.Trim()); + if (string.Equals(normalized, OpenAIProtocolId, StringComparison.OrdinalIgnoreCase)) + { + providerType = ProviderType.OpenAICompatible; + return true; + } + if (string.Equals(normalized, AnthropicProtocolId, StringComparison.OrdinalIgnoreCase)) + { + providerType = ProviderType.AnthropicClaude; + return true; + } + if (string.Equals(normalized, LocalGatewayProtocolId, StringComparison.OrdinalIgnoreCase)) + { + providerType = ProviderType.LocalSubscriptionGateway; + return true; + } + if (string.Equals(normalized, OpenCodeGoProtocolId, StringComparison.OrdinalIgnoreCase)) + { + providerType = ProviderType.OpenCodeGo; + return true; + } + return false; + } + + private static string NormalizeAlias(string id) + { + if (string.Equals(id, nameof(ProviderType.OpenAICompatible), StringComparison.OrdinalIgnoreCase)) + return OpenAIProtocolId; + if (string.Equals(id, nameof(ProviderType.AnthropicClaude), StringComparison.OrdinalIgnoreCase)) + return AnthropicProtocolId; + if (string.Equals(id, nameof(ProviderType.LocalSubscriptionGateway), StringComparison.OrdinalIgnoreCase)) + return LocalGatewayProtocolId; + if (string.Equals(id, nameof(ProviderType.OpenCodeGo), StringComparison.OrdinalIgnoreCase)) + return OpenCodeGoProtocolId; + return id; + } + + private static void RegisterCore(IModelProtocol protocol) + { + string key = protocol.ProtocolId.Trim(); + int existingIndex = OrderedProtocols.FindIndex(p => + string.Equals(p.ProtocolId, key, StringComparison.OrdinalIgnoreCase)); + if (existingIndex >= 0) + { + OrderedProtocols[existingIndex] = protocol; + } + else + { + OrderedProtocols.Add(protocol); + } + ProtocolsById[key] = protocol; + } + + internal static void ApplyOpenCodeSessionHeader(HttpRequestMessage request, string? requestId, int fallbackGuidLength) + { + string sessionId = !string.IsNullOrEmpty(requestId) + ? requestId! + : Guid.NewGuid().ToString("N").Substring(0, fallbackGuidLength); + string headerValue = sessionId.StartsWith("rimmind-", StringComparison.OrdinalIgnoreCase) + ? sessionId + : "rimmind-" + sessionId; + request.Headers.TryAddWithoutValidation("x-opencode-session", headerValue); + } + } + + public class OpenAIModelProtocol : IModelProtocol + { + public static readonly OpenAIModelProtocol Instance = new OpenAIModelProtocol(); + + public virtual string ProtocolId => ModelProtocolRegistry.OpenAIProtocolId; + public virtual ProviderType LegacyProviderType => ProviderType.OpenAICompatible; + public virtual string DisplayLabelKey => "RimMind.ModelService.Provider.OpenAICompatible"; + public virtual string FallbackLabel => "OpenAI Compatible"; + public virtual string DefaultEndpointUrl => "https://api.openai.com/v1"; + public virtual string DefaultModel => "gpt-4o-mini"; + + public virtual Result BuildRequestJson(LlmRequestEnvelope envelope, string modelOverride) + { + if (envelope == null) + return Result.Err(RimMindErrors.ClientPermanent("Request envelope is null.")); + + string effectiveModel = !string.IsNullOrEmpty(modelOverride) ? modelOverride : DefaultModel; + var node = new ModelEndpointConfig { modelName = effectiveModel, protocolId = ProtocolId, providerType = LegacyProviderType }; + return Result.Ok(OpenAIProtocolAdapter.BuildRequestJson(envelope, node)); + } + + public virtual Result ParseResponse(string responseBody, LlmRequestEnvelope? envelope = null) + { + return OpenAIProtocolAdapter.ParseResponse(responseBody, envelope); + } + + public virtual void ApplyHeaders(HttpRequestMessage request, ModelEndpointConfig endpoint, string? requestId) + { + if (request == null || endpoint == null) return; + + if (request.RequestUri == null && !string.IsNullOrWhiteSpace(endpoint.endpoint)) + { + request.RequestUri = new Uri(endpoint.endpoint.TrimEnd('/') + "/chat/completions"); + } + + if (!string.IsNullOrEmpty(endpoint.apiKey)) + { + request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + endpoint.apiKey); + } + + string urlStr = request.RequestUri?.OriginalString ?? endpoint.endpoint ?? string.Empty; + if ((!string.IsNullOrEmpty(urlStr) && urlStr.IndexOf("opencode", StringComparison.OrdinalIgnoreCase) >= 0) + || (!string.IsNullOrEmpty(endpoint.apiKey) && endpoint.apiKey.IndexOf("oc_sk_", StringComparison.OrdinalIgnoreCase) >= 0)) + { + ModelProtocolRegistry.ApplyOpenCodeSessionHeader(request, requestId, fallbackGuidLength: 12); + } + } + + public virtual HttpRequestMessage BuildHealthProbeRequest(ModelEndpointConfig endpoint, string resolvedUrl) + { + var request = new HttpRequestMessage(HttpMethod.Get, resolvedUrl); + ApplyHeaders(request, endpoint, "health-probe"); + return request; + } + } + + public sealed class LocalGatewayModelProtocol : OpenAIModelProtocol + { + public static new readonly LocalGatewayModelProtocol Instance = new LocalGatewayModelProtocol(); + + public override string ProtocolId => ModelProtocolRegistry.LocalGatewayProtocolId; + public override ProviderType LegacyProviderType => ProviderType.LocalSubscriptionGateway; + public override string DisplayLabelKey => "RimMind.ModelService.Provider.LocalSubscriptionGateway"; + public override string FallbackLabel => "Local Gateway (sub2api)"; + public override string DefaultEndpointUrl => "http://127.0.0.1:8000/v1"; + public override string DefaultModel => "gpt-4o"; + } + + public sealed class OpenCodeGoModelProtocol : OpenAIModelProtocol + { + public static new readonly OpenCodeGoModelProtocol Instance = new OpenCodeGoModelProtocol(); + + public override string ProtocolId => ModelProtocolRegistry.OpenCodeGoProtocolId; + public override ProviderType LegacyProviderType => ProviderType.OpenCodeGo; + public override string DisplayLabelKey => "RimMind.ModelService.Provider.OpenCodeGo"; + public override string FallbackLabel => "OpenCode Go (Subscription)"; + public override string DefaultEndpointUrl => "https://opencode.ai/zen/go/v1"; + public override string DefaultModel => "deepseek-v4.1-flash"; + + public override void ApplyHeaders(HttpRequestMessage request, ModelEndpointConfig endpoint, string? requestId) + { + if (request == null || endpoint == null) return; + + if (request.RequestUri == null && !string.IsNullOrWhiteSpace(endpoint.endpoint)) + { + request.RequestUri = new Uri(endpoint.endpoint.TrimEnd('/') + "/chat/completions"); + } + + if (!string.IsNullOrEmpty(endpoint.apiKey)) + { + request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + endpoint.apiKey); + } + + ModelProtocolRegistry.ApplyOpenCodeSessionHeader(request, requestId, fallbackGuidLength: 16); + } + } + + public sealed class AnthropicModelProtocol : IModelProtocol + { + public static readonly AnthropicModelProtocol Instance = new AnthropicModelProtocol(); + + public string ProtocolId => ModelProtocolRegistry.AnthropicProtocolId; + public ProviderType LegacyProviderType => ProviderType.AnthropicClaude; + public string DisplayLabelKey => "RimMind.ModelService.Provider.AnthropicClaude"; + public string FallbackLabel => "Anthropic Claude Native"; + public string DefaultEndpointUrl => "https://api.anthropic.com/v1"; + public string DefaultModel => "claude-3-5-haiku-20241022"; + + public Result BuildRequestJson(LlmRequestEnvelope envelope, string modelOverride) + { + if (envelope == null) + return Result.Err(RimMindErrors.ClientPermanent("Request envelope is null.")); + + string effectiveModel = !string.IsNullOrEmpty(modelOverride) ? modelOverride : DefaultModel; + var node = new ModelEndpointConfig { modelName = effectiveModel, protocolId = ProtocolId, providerType = LegacyProviderType }; + return Result.Ok(AnthropicProtocolAdapter.BuildRequestJson(envelope, node)); + } + + public Result ParseResponse(string responseBody, LlmRequestEnvelope? envelope = null) + { + return AnthropicProtocolAdapter.ParseResponse(responseBody); + } + + public void ApplyHeaders(HttpRequestMessage request, ModelEndpointConfig endpoint, string? requestId) + { + if (request == null || endpoint == null) return; + + if (request.Method == HttpMethod.Post && !string.IsNullOrWhiteSpace(endpoint.endpoint)) + { + request.RequestUri = new Uri(endpoint.endpoint.TrimEnd('/') + "/messages"); + } + + if (!string.IsNullOrEmpty(endpoint.apiKey)) + { + request.Headers.TryAddWithoutValidation("x-api-key", endpoint.apiKey); + request.Headers.TryAddWithoutValidation("anthropic-version", "2023-06-01"); + } + else if (request.Method == HttpMethod.Post) + { + request.Headers.TryAddWithoutValidation("x-api-key", string.Empty); + request.Headers.TryAddWithoutValidation("anthropic-version", "2023-06-01"); + } + } + + public HttpRequestMessage BuildHealthProbeRequest(ModelEndpointConfig endpoint, string resolvedUrl) + { + var request = new HttpRequestMessage(HttpMethod.Get, resolvedUrl); + ApplyHeaders(request, endpoint, "health-probe"); + return request; + } + } +} diff --git a/Source/Protocol/OpenAIProtocolAdapter.cs b/Source/Protocol/OpenAIProtocolAdapter.cs index 4d607d4..0dad355 100644 --- a/Source/Protocol/OpenAIProtocolAdapter.cs +++ b/Source/Protocol/OpenAIProtocolAdapter.cs @@ -1,182 +1,40 @@ using System; -using System.Collections.Generic; -using System.Linq; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using RimMind.Domain.Common; using RimMind.Domain.Llm; using RimMind.Domain.ValueObjects; +using RimMind.Infrastructure.Services.Clients.OpenAI; using RimMind.ModelService.Models; namespace RimMind.ModelService.Protocol { + /// + /// OpenAI chat-completions wire adapter for ModelService endpoints. + /// Delegates directly to Core's so request serialization + /// and response parsing stay 100% shared with Core's built-in client. + /// public static class OpenAIProtocolAdapter { + private const string DefaultModelName = "gpt-4o-mini"; + public static string BuildRequestJson(LlmRequestEnvelope envelope, ModelEndpointConfig node) { - var root = new JObject - { - ["model"] = !string.IsNullOrEmpty(node.modelName) ? node.modelName : "gpt-4o-mini", - ["temperature"] = envelope.Temperature, - }; - - if (envelope.MaxTokens > 0) - { - root["max_tokens"] = envelope.MaxTokens; - } - - var messagesArray = new JArray(); - - if (envelope.Messages != null && envelope.Messages.Count > 0) - { - foreach (var msg in envelope.Messages) - { - var msgObj = new JObject - { - ["role"] = msg.Role, - ["content"] = msg.Content ?? "" - }; - - if (msg.ToolCalls != null && msg.ToolCalls.Count > 0) - { - var toolCallsArr = new JArray(); - foreach (var tc in msg.ToolCalls) - { - toolCallsArr.Add(new JObject - { - ["id"] = tc.Id ?? Guid.NewGuid().ToString("N"), - ["type"] = "function", - ["function"] = new JObject - { - ["name"] = tc.Name, - ["arguments"] = tc.Arguments ?? "{}" - } - }); - } - msgObj["tool_calls"] = toolCallsArr; - } - - if (!string.IsNullOrEmpty(msg.ToolCallId)) - { - msgObj["tool_call_id"] = msg.ToolCallId; - } - - messagesArray.Add(msgObj); - } - } - - root["messages"] = messagesArray; - - if (envelope.Tools != null && envelope.Tools.Count > 0) - { - var toolsArray = new JArray(); - foreach (var tool in envelope.Tools.OrderBy(t => t.Name, StringComparer.Ordinal)) - { - var funcObj = new JObject - { - ["name"] = tool.Name, - ["description"] = tool.Description ?? "" - }; - - if (!string.IsNullOrEmpty(tool.Parameters)) - { - try - { - funcObj["parameters"] = JToken.Parse(tool.Parameters!); - } - catch - { - funcObj["parameters"] = new JObject { ["type"] = "object" }; - } - } - - toolsArray.Add(new JObject - { - ["type"] = "function", - ["function"] = funcObj - }); - } - root["tools"] = toolsArray; - } - - return root.ToString(Formatting.None); + if (envelope == null) throw new ArgumentNullException(nameof(envelope)); + string modelName = node != null && !string.IsNullOrEmpty(node.modelName) + ? node.modelName + : DefaultModelName; + + return OpenAIRequestSerializer.BuildRequestJson( + envelope, + modelName, + defaultMaxTokens: 0, + stream: false, + forceJsonMode: false, + omitZeroMaxTokens: true); } public static Result ParseResponse(string responseJson) - { - if (string.IsNullOrWhiteSpace(responseJson)) - return Result.Err(RimMindErrors.ClientTransient("Empty response body from model server.")); - - try - { - var token = JToken.Parse(responseJson); - - if (token["error"] != null) - { - string errMsg = token["error"]?["message"]?.ToString() ?? "Unknown API error"; - return Result.Err(RimMindErrors.ClientPermanent(errMsg)); - } - - var choices = token["choices"] as JArray; - if (choices == null || choices.Count == 0) - { - return Result.Err(RimMindErrors.ClientPermanent("Response choices array is missing or empty.")); - } - - var message = choices[0]["message"]; - if (message == null) - { - return Result.Err(RimMindErrors.ClientPermanent("First choice message is null.")); - } + => OpenAIRequestSerializer.ParseResponse(responseJson, null); - string content = message["content"]?.ToString() ?? ""; - string reasoningContent = message["reasoning_content"]?.ToString() ?? ""; - string toolCallsJson = ""; - - var toolCalls = message["tool_calls"] as JArray; - if (toolCalls != null && toolCalls.Count > 0) - { - var wireList = new List(); - foreach (var tc in toolCalls) - { - string id = tc["id"]?.ToString() ?? Guid.NewGuid().ToString("N"); - string name = tc["function"]?["name"]?.ToString() ?? ""; - string arguments = tc["function"]?["arguments"]?.ToString() ?? "{}"; - wireList.Add(new - { - id = id, - type = "function", - function = new - { - name = name, - arguments = arguments - } - }); - } - toolCallsJson = JsonConvert.SerializeObject(wireList); - } - - int promptTokens = token["usage"]?["prompt_tokens"]?.Value() ?? 0; - int completionTokens = token["usage"]?["completion_tokens"]?.Value() ?? 0; - int totalTokens = token["usage"]?["total_tokens"]?.Value() ?? (promptTokens + completionTokens); - - var llmResponse = new LlmResponse - { - RequestId = token["id"]?.ToString() ?? Guid.NewGuid().ToString("N"), - Content = content, - ReasoningContent = reasoningContent, - ToolCallsJson = toolCallsJson, - PromptTokens = promptTokens, - CompletionTokens = completionTokens, - TokensUsed = totalTokens - }; - - return Result.Ok(llmResponse); - } - catch (Exception ex) - { - return Result.Err(RimMindErrors.ClientPermanent($"Failed to parse OpenAI response: {ex.Message}")); - } - } + public static Result ParseResponse(string responseJson, LlmRequestEnvelope? envelope) + => OpenAIRequestSerializer.ParseResponse(responseJson, envelope); } } diff --git a/Source/RimMindModelServiceMod.cs b/Source/RimMindModelServiceMod.cs index 4ba657f..eb35b0b 100644 --- a/Source/RimMindModelServiceMod.cs +++ b/Source/RimMindModelServiceMod.cs @@ -38,6 +38,8 @@ static ModelServiceInitializer() { try { + ModelServiceClientFactory.RegisterDescriptor(); + var factoryRegistry = RimMindAPI.Ext.Get(); if (factoryRegistry != null) { diff --git a/Source/Security/SecretRedactor.cs b/Source/Security/SecretRedactor.cs new file mode 100644 index 0000000..286547a --- /dev/null +++ b/Source/Security/SecretRedactor.cs @@ -0,0 +1,26 @@ +using RimMind.Infrastructure.Services.Clients.Shared; + +namespace RimMind.ModelService.Security +{ + /// + /// Credential redaction for ModelService error messages and diagnostics. + /// Delegates directly to Core's so redaction patterns, + /// truncation markers, and literal-secret masking stay unified across Core and ModelService. + /// + internal static class SecretRedactor + { + public const int DefaultMaxChars = LogSanitizer.DefaultMaxChars; + + public static string Redact(string? text) + => LogSanitizer.Redact(text); + + public static string ForLog(string? text, int maxChars = DefaultMaxChars) + => LogSanitizer.ForLog(text, maxChars); + + public static string ForMessage(string? text, int maxChars = DefaultMaxChars) + => LogSanitizer.ForLog(text, maxChars); + + public static string ForMessageWithSecret(string? text, string? knownSecret, int maxChars = DefaultMaxChars) + => LogSanitizer.ForMessageWithSecret(text, knownSecret, maxChars); + } +} diff --git a/Source/Settings/ModelServiceSettingsDrawer.cs b/Source/Settings/ModelServiceSettingsDrawer.cs index bb8c2db..f7e9d69 100644 --- a/Source/Settings/ModelServiceSettingsDrawer.cs +++ b/Source/Settings/ModelServiceSettingsDrawer.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using RimMind.ModelService.Diagnostics; using RimMind.ModelService.Models; +using RimMind.ModelService.Protocol; using RimMind.ModelService.Security; using RimMind.Presentation.UI; using UnityEngine; @@ -97,12 +98,14 @@ public static void DrawSettings(Rect inRect, ModelServiceSettings settings) { lock (settings.EndpointSyncRoot) { + var defaultProtocol = ModelProtocolRegistry.Resolve(ModelProtocolRegistry.OpenAIProtocolId); settings.endpoints.Add(new ModelEndpointConfig { name = $"Node {settings.endpoints.Count + 1}", endpoint = "http://127.0.0.1:8000/v1", - modelName = "gpt-4o-mini", - providerType = ProviderType.OpenAICompatible, + modelName = defaultProtocol.DefaultModel, + providerType = defaultProtocol.LegacyProviderType, + protocolId = defaultProtocol.ProtocolId, priority = settings.endpoints.Count, weight = 1, isEnabled = true @@ -219,28 +222,22 @@ public static void DrawSettings(Rect inRect, ModelServiceSettings settings) GUI.color = Color.white; } - // --- Row 2: Provider selection button (FloatMenu), URL text field --- + // --- Row 2: Protocol selection button (FloatMenu), URL text field --- float row2Y = cardRect.y + 42f; - string providerLabel = endpoint.providerType switch - { - ProviderType.OpenAICompatible => "RimMind.ModelService.Provider.OpenAICompatible".Translate(), - ProviderType.AnthropicClaude => "RimMind.ModelService.Provider.AnthropicClaude".Translate(), - ProviderType.LocalSubscriptionGateway => "RimMind.ModelService.Provider.LocalSubscriptionGateway".Translate(), - ProviderType.OpenCodeGo => "RimMind.ModelService.Provider.OpenCodeGo".Translate(), - _ => endpoint.providerType.ToString() - }; + var currentProtocol = ModelProtocolRegistry.Resolve(endpoint); + string providerLabel = GetProtocolDisplayLabel(currentProtocol); var provRect = new Rect(leftX, row2Y, 210f, 26f); if (Widgets.ButtonText(provRect, providerLabel)) { - var options = new List + var options = new List(); + foreach (var protocol in ModelProtocolRegistry.All) { - new FloatMenuOption("RimMind.ModelService.Provider.OpenAICompatible".Translate(), () => endpoint.providerType = ProviderType.OpenAICompatible), - new FloatMenuOption("RimMind.ModelService.Provider.AnthropicClaude".Translate(), () => endpoint.providerType = ProviderType.AnthropicClaude), - new FloatMenuOption("RimMind.ModelService.Provider.LocalSubscriptionGateway".Translate(), () => endpoint.providerType = ProviderType.LocalSubscriptionGateway), - new FloatMenuOption("RimMind.ModelService.Provider.OpenCodeGo".Translate(), () => endpoint.providerType = ProviderType.OpenCodeGo) - }; + var capturedProtocol = protocol; + string optionLabel = GetProtocolDisplayLabel(capturedProtocol); + options.Add(new FloatMenuOption(optionLabel, () => ApplyProtocolSelection(endpoint, capturedProtocol))); + } Find.WindowStack.Add(new FloatMenu(options)); } TooltipHandler.TipRegion(provRect, providerLabel); @@ -349,7 +346,7 @@ public static void DrawSettings(Rect inRect, ModelServiceSettings settings) TooltipHandler.TipRegion(statusRect, $"{statusIcon} {latencyStr} ({statusDetail})"); // Security Banner (if LocalSubscriptionGateway: 🛡️ 本地回环安全锁定 if loopback, or ⚠️ 凭据安全拦截 if not loopback) - if (endpoint.providerType == ProviderType.LocalSubscriptionGateway) + if (currentProtocol.LegacyProviderType == ProviderType.LocalSubscriptionGateway) { bool isLoopback = endpoint.IsLoopbackAddress; string bannerText = isLoopback @@ -370,7 +367,7 @@ public static void DrawSettings(Rect inRect, ModelServiceSettings settings) : "RimMind.ModelService.Security.LoopbackViolation".Translate(); TooltipHandler.TipRegion(bannerRect, tip); } - else if (endpoint.providerType == ProviderType.OpenCodeGo) + else if (string.Equals(currentProtocol.ProtocolId, ModelProtocolRegistry.OpenCodeGoProtocolId, StringComparison.OrdinalIgnoreCase)) { string bannerText = "RimMind.ModelService.Security.OpenCodeGoSessionRouted".Translate(); float bannerX = statusX + 165f; @@ -417,6 +414,40 @@ public static void DrawSettings(Rect inRect, ModelServiceSettings settings) listing.End(); } + internal static string GetProtocolDisplayLabel(IModelProtocol protocol) + { + if (protocol == null) return string.Empty; + if (!string.IsNullOrEmpty(protocol.DisplayLabelKey) && protocol.DisplayLabelKey.CanTranslate()) + { + return protocol.DisplayLabelKey.Translate(); + } + return !string.IsNullOrEmpty(protocol.FallbackLabel) ? protocol.FallbackLabel : protocol.ProtocolId; + } + + internal static void ApplyProtocolSelection(ModelEndpointConfig endpoint, IModelProtocol selectedProtocol) + { + if (endpoint == null || selectedProtocol == null) return; + + var previousProtocol = ModelProtocolRegistry.Resolve(endpoint); + bool updateUrl = string.IsNullOrWhiteSpace(endpoint.endpoint) + || string.Equals(endpoint.endpoint, previousProtocol.DefaultEndpointUrl, StringComparison.OrdinalIgnoreCase); + bool updateModel = string.IsNullOrWhiteSpace(endpoint.modelName) + || string.Equals(endpoint.modelName, previousProtocol.DefaultModel, StringComparison.OrdinalIgnoreCase); + + endpoint.protocolId = selectedProtocol.ProtocolId; + endpoint.providerType = selectedProtocol.LegacyProviderType; + + if (updateUrl && !string.IsNullOrWhiteSpace(selectedProtocol.DefaultEndpointUrl)) + { + endpoint.endpoint = selectedProtocol.DefaultEndpointUrl; + } + + if (updateModel && !string.IsNullOrWhiteSpace(selectedProtocol.DefaultModel)) + { + endpoint.modelName = selectedProtocol.DefaultModel; + } + } + private static void SyncPriorities(List endpoints) { for (int i = 0; i < endpoints.Count; i++) diff --git a/Tests/Contracts/ModelServiceContracts.cs b/Tests/Contracts/ModelServiceContracts.cs index 5dd6508..a4ebb66 100644 --- a/Tests/Contracts/ModelServiceContracts.cs +++ b/Tests/Contracts/ModelServiceContracts.cs @@ -979,6 +979,267 @@ public async Task ModelServiceClient_permanent_vs_transient_failover_and_failure } } + [Fact] + public async Task ModelProtocolRegistry_custom_protocol_works_end_to_end_through_client_and_health_probe() + { + var customProtocol = new CustomGeminiNativeProtocol(); + ModelProtocolRegistry.Register(customProtocol); + try + { + Assert.Contains(ModelProtocolRegistry.All, p => p.ProtocolId == "gemini_native"); + Assert.Same(customProtocol, ModelProtocolRegistry.Resolve("GEMINI_NATIVE")); + + var endpoint = new ModelEndpointConfig + { + id = "gemini-node-1", + name = "Gemini Native Node", + endpoint = "https://generativelanguage.googleapis.com/v1beta", + modelName = "", + apiKey = "gkey_test_12345", + protocolId = "gemini_native", + isEnabled = true + }; + + Assert.Equal("gemini_native", endpoint.EffectiveProtocolId); + Assert.Same(customProtocol, ModelProtocolRegistry.Resolve(endpoint)); + + System.Net.Http.HttpRequestMessage? capturedClientRequest = null; + string? capturedClientPayload = null; + + using (var client = new ModelServiceClient( + () => new List { endpoint }, + () => BalancingStrategy.PriorityFailover, + loadBalancer: null, + httpSendOverride: async (req, _) => + { + capturedClientRequest = new System.Net.Http.HttpRequestMessage(req.Method, req.RequestUri); + foreach (var header in req.Headers) + { + capturedClientRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + capturedClientPayload = req.Content != null ? await req.Content.ReadAsStringAsync() : null; + return new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new System.Net.Http.StringContent( + "{\"candidates\":[{\"text\":\"Gemini native reply\"}],\"tokens\":77}", + System.Text.Encoding.UTF8, + "application/json") + }; + })) + { + var envelope = new LlmRequestEnvelope + { + RequestId = "req-gemini-e2e", + Messages = new List + { + new ChatMessage { Role = "user", Content = "Ping Gemini" } + } + }; + + var response = await client.SendAsync(envelope); + Assert.True(response.IsOk); + Assert.Equal("Gemini native reply", response.Value.Content); + Assert.Equal(77, response.Value.TokensUsed); + Assert.Equal("req-gemini-e2e", response.Value.RequestId); + + Assert.NotNull(capturedClientRequest); + Assert.Equal( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", + capturedClientRequest!.RequestUri?.ToString()); + Assert.True(capturedClientRequest.Headers.TryGetValues("x-goog-api-key", out var keyVals)); + Assert.Equal("gkey_test_12345", keyVals!.Single()); + Assert.True(capturedClientRequest.Headers.TryGetValues("x-gemini-request-id", out var reqVals)); + Assert.Equal("req-gemini-e2e", reqVals!.Single()); + Assert.NotNull(capturedClientPayload); + Assert.Contains("gemini-2.5-flash", capturedClientPayload); + Assert.Contains("Ping Gemini", capturedClientPayload); + } + + System.Net.Http.HttpRequestMessage? capturedProbeRequest = null; + var (probeSuccess, probeLatency, probeMsg) = await RimMind.ModelService.Diagnostics.EndpointHealthProbe.ProbeEndpointAsync( + endpoint, + default, + (req, _) => + { + capturedProbeRequest = new System.Net.Http.HttpRequestMessage(req.Method, req.RequestUri); + foreach (var header in req.Headers) + { + capturedProbeRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + return Task.FromResult(new System.Net.Http.HttpResponseMessage(System.Net.HttpStatusCode.OK)); + }); + + Assert.True(probeSuccess, probeMsg); + Assert.True(probeLatency >= 0); + Assert.True(endpoint.isHealthy); + Assert.NotNull(capturedProbeRequest); + Assert.True(capturedProbeRequest!.Headers.TryGetValues("x-goog-api-key", out var probeKeyVals)); + Assert.Equal("gkey_test_12345", probeKeyVals!.Single()); + Assert.True(capturedProbeRequest.Headers.TryGetValues("x-gemini-probe", out var probeFlagVals)); + Assert.Equal("true", probeFlagVals!.Single()); + } + finally + { + ModelProtocolRegistry.Unregister("gemini_native"); + } + } + + [Fact] + public void ModelEndpointConfig_preserves_legacy_ProviderType_and_custom_protocolId_across_Scribe() + { + ContractCaseRunner.Run( + ("built-in providerType and protocolId stay synchronized on mutation", () => + { + var cfg = new ModelEndpointConfig(); + Assert.Equal(ProviderType.OpenAICompatible, cfg.providerType); + Assert.Equal("openai", cfg.EffectiveProtocolId); + + cfg.providerType = ProviderType.AnthropicClaude; + Assert.Equal("anthropic", cfg.protocolId); + Assert.Equal("anthropic", cfg.EffectiveProtocolId); + + cfg.protocolId = "opencode_go"; + Assert.Equal(ProviderType.OpenCodeGo, cfg.providerType); + Assert.Equal("opencode_go", cfg.EffectiveProtocolId); + + cfg.protocolId = "local_gateway"; + Assert.Equal(ProviderType.LocalSubscriptionGateway, cfg.providerType); + Assert.Equal("local_gateway", cfg.EffectiveProtocolId); + + cfg.protocolId = "custom_gateway"; + Assert.Equal("custom_gateway", cfg.protocolId); + Assert.Equal("custom_gateway", cfg.EffectiveProtocolId); + }), + ("legacy save with only providerType synchronizes protocolId on load", () => + { + var legacyBuffer = new Dictionary + { + ["id"] = "legacy-1", + ["name"] = "Legacy Claude Node", + ["providerType"] = ProviderType.AnthropicClaude, + ["endpoint"] = "https://api.anthropic.com/v1", + ["modelName"] = "claude-3-5-sonnet-20241022", + ["isEnabled"] = true + }; + + var loaded = new ModelEndpointConfig(); + try + { + Verse.Scribe_Values.BeginLoad(legacyBuffer, Verse.LoadSaveMode.LoadingVars); + loaded.ExposeData(); + Verse.Scribe.mode = Verse.LoadSaveMode.PostLoadInit; + loaded.ExposeData(); + } + finally + { + Verse.Scribe_Values.End(); + } + + Assert.Equal(ProviderType.AnthropicClaude, loaded.providerType); + Assert.Equal("anthropic", loaded.protocolId); + Assert.Equal("anthropic", loaded.EffectiveProtocolId); + Assert.Same(AnthropicModelProtocol.Instance, ModelProtocolRegistry.Resolve(loaded)); + }), + ("custom protocolId round-trips across Scribe save and load without loss", () => + { + var original = new ModelEndpointConfig + { + id = "custom-1", + name = "Custom Gateway", + endpoint = "https://custom.gateway/api", + modelName = "custom-model-v1", + protocolId = "custom_gateway", + priority = 2, + weight = 3, + timeoutSeconds = 45, + isEnabled = true + }; + + var buffer = new Dictionary(); + var rehydrated = new ModelEndpointConfig(); + try + { + Verse.Scribe_Values.BeginSave(buffer); + original.ExposeData(); + + Verse.Scribe_Values.BeginLoad(buffer, Verse.LoadSaveMode.LoadingVars); + rehydrated.ExposeData(); + Verse.Scribe.mode = Verse.LoadSaveMode.PostLoadInit; + rehydrated.ExposeData(); + } + finally + { + Verse.Scribe_Values.End(); + } + + Assert.Equal("custom-1", rehydrated.id); + Assert.Equal("Custom Gateway", rehydrated.name); + Assert.Equal("custom_gateway", rehydrated.protocolId); + Assert.Equal("custom_gateway", rehydrated.EffectiveProtocolId); + Assert.Equal(ProviderType.OpenAICompatible, rehydrated.providerType); + Assert.Equal(45, rehydrated.timeoutSeconds); + })); + } + + private sealed class CustomGeminiNativeProtocol : IModelProtocol + { + public string ProtocolId => "gemini_native"; + public ProviderType LegacyProviderType => ProviderType.OpenAICompatible; + public string DisplayLabelKey => "RimMind.ModelService.Provider.GeminiNative"; + public string FallbackLabel => "Gemini Native"; + public string DefaultEndpointUrl => "https://generativelanguage.googleapis.com/v1beta"; + public string DefaultModel => "gemini-2.5-flash"; + + public Result BuildRequestJson(LlmRequestEnvelope envelope, string modelOverride) + { + string prompt = envelope.Messages?.LastOrDefault()?.Content ?? ""; + var obj = new Newtonsoft.Json.Linq.JObject + { + ["model"] = modelOverride, + ["prompt"] = prompt + }; + return Result.Ok(obj.ToString(Newtonsoft.Json.Formatting.None)); + } + + public Result ParseResponse(string responseBody, LlmRequestEnvelope? envelope = null) + { + var obj = Newtonsoft.Json.Linq.JObject.Parse(responseBody); + string text = obj["candidates"]?[0]?["text"]?.ToString() ?? ""; + int tokens = obj["tokens"]?.ToObject() ?? 0; + return Result.Ok(new LlmResponse + { + RequestId = envelope?.RequestId ?? "gemini-resp", + Content = text, + TokensUsed = tokens + }); + } + + public void ApplyHeaders(System.Net.Http.HttpRequestMessage request, ModelEndpointConfig endpoint, string? requestId) + { + string model = !string.IsNullOrEmpty(endpoint.modelName) ? endpoint.modelName : DefaultModel; + request.RequestUri = new Uri(endpoint.endpoint.TrimEnd('/') + "/models/" + model + ":generateContent"); + if (!string.IsNullOrEmpty(endpoint.apiKey)) + { + request.Headers.TryAddWithoutValidation("x-goog-api-key", endpoint.apiKey); + } + if (!string.IsNullOrEmpty(requestId)) + { + request.Headers.TryAddWithoutValidation("x-gemini-request-id", requestId); + } + } + + public System.Net.Http.HttpRequestMessage BuildHealthProbeRequest(ModelEndpointConfig endpoint, string resolvedUrl) + { + var req = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Get, resolvedUrl); + if (!string.IsNullOrEmpty(endpoint.apiKey)) + { + req.Headers.TryAddWithoutValidation("x-goog-api-key", endpoint.apiKey); + } + req.Headers.TryAddWithoutValidation("x-gemini-probe", "true"); + return req; + } + } + private sealed class CoreSettingsStub : RimMind.Application.Common.Interfaces.Internal.ISettingsProvider { public string ApiKey { get; set; } = ""; diff --git a/Tests/Contracts/OpenAIProtocolParityContracts.cs b/Tests/Contracts/OpenAIProtocolParityContracts.cs new file mode 100644 index 0000000..c6c6147 --- /dev/null +++ b/Tests/Contracts/OpenAIProtocolParityContracts.cs @@ -0,0 +1,302 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using RimMind.Domain.Llm; +using RimMind.Infrastructure.Services.Clients.OpenAI; +using RimMind.Infrastructure.Services.Clients.Shared; +using RimMind.ModelService.Models; +using RimMind.ModelService.Protocol; +using RimMind.ModelService.Security; +using RimMind.Testing; +using Xunit; + +namespace RimMind.ModelService.Tests.Contracts +{ + /// + /// Golden-JSON parity between ModelService's OpenAI adapter and Core's built-in serializer. + /// Core's internal OpenAIRequestSerializer and LogSanitizer are compiled into this + /// test assembly (test-only source links) so both outputs are compared byte-for-byte. + /// + public class OpenAIProtocolParityContracts + { + private const string Model = "test-model"; + + private const string GoldenToolLoopRequest = + "{\"model\":\"test-model\",\"messages\":[" + + "{\"role\":\"system\",\"content\":\"S\"}," + + "{\"role\":\"user\",\"content\":\"EU\"}," + + "{\"role\":\"assistant\",\"content\":\"EA\"}," + + "{\"role\":\"user\",\"content\":\"U1\"}," + + "{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":\"R\",\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"actions_move_pawn\",\"arguments\":\"{\\\"x\\\":1}\"}}]}," + + "{\"role\":\"tool\",\"content\":\"ok\",\"tool_call_id\":\"call_1\"}]," + + "\"max_tokens\":256,\"temperature\":0.5,\"stream\":false," + + "\"response_format\":{\"type\":\"json_schema\",\"json_schema\":{\"name\":\"response\",\"schema\":{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"string\"}}}}}," + + "\"tools\":[" + + "{\"type\":\"function\",\"function\":{\"name\":\"actions_move_pawn\",\"description\":\"Move\",\"parameters\":{\"type\":\"object\",\"properties\":{}}}}," + + "{\"type\":\"function\",\"function\":{\"name\":\"core_query\",\"description\":\"Q\",\"parameters\":{\"type\":\"object\"}}}]," + + "\"tool_choice\":\"required\"}"; + + private static ModelEndpointConfig Node() => new ModelEndpointConfig { modelName = Model }; + + private static LlmRequestEnvelope ToolLoopEnvelope() => new LlmRequestEnvelope + { + RequestId = "req-parity", + MaxTokens = 256, + Temperature = 0.5f, + JsonSchema = "{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"string\"}}}", + Messages = new List + { + new ChatMessage { Role = "system", Content = "S" }, + new ChatMessage { Role = "user", Content = "U1" }, + new ChatMessage + { + Role = "assistant", + Content = "", + ReasoningContent = "R", + ToolCalls = new List + { + new ChatToolCall { Id = "call_1", Name = "actions_move_pawn", Arguments = "{\"x\":1}" } + } + }, + new ChatMessage { Role = "tool", Content = "ok", ToolCallId = "call_1" }, + }, + Examples = new List + { + new ChatMessage { Role = "user", Content = "EU" }, + new ChatMessage { Role = "assistant", Content = "EA" }, + }, + Tools = new List + { + new StructuredTool { Name = "core.query", Description = "Q", Parameters = "{\"type\":\"object\"}" }, + new StructuredTool { Name = "actions.move_pawn", Description = "Move", Parameters = null, ToolChoice = "required" }, + }, + }; + + private static string Core(LlmRequestEnvelope envelope) => + OpenAIRequestSerializer.BuildRequestJson(envelope, Model, defaultMaxTokens: 0); + + [Fact] + public void OpenAI_request_wire_format_matches_core_serializer() + { + ContractCaseRunner.Run( + ("tool loop with dotted tools, schema, examples and tool_choice=required is byte-identical to Core and the golden JSON", () => + { + var envelope = ToolLoopEnvelope(); + string adapter = OpenAIProtocolAdapter.BuildRequestJson(envelope, Node()); + + Assert.Equal(GoldenToolLoopRequest, Core(envelope)); + Assert.Equal(GoldenToolLoopRequest, adapter); + }), + ("text sentinel schema emits no response_format and tool_choice falls back to auto, same as Core", () => + { + var envelope = new LlmRequestEnvelope + { + MaxTokens = 120, + Temperature = 0.25f, + JsonSchema = "...", + Messages = new List { new ChatMessage { Role = "user", Content = "hi" } }, + Tools = new List + { + new StructuredTool { Name = "dialogue.reply", Description = "Reply", Parameters = "{\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}" }, + }, + }; + + string adapter = OpenAIProtocolAdapter.BuildRequestJson(envelope, Node()); + Assert.Equal(Core(envelope), adapter); + + var parsed = JObject.Parse(adapter); + Assert.Null(parsed["response_format"]); + Assert.Equal("auto", parsed["tool_choice"]?.ToString()); + Assert.Equal("dialogue_reply", parsed["tools"]?[0]?["function"]?["name"]?.ToString()); + }), + ("plain chat without tools matches Core and carries no tools or tool_choice", () => + { + var envelope = new LlmRequestEnvelope + { + MaxTokens = 64, + Temperature = 0.7f, + Messages = new List + { + new ChatMessage { Role = "system", Content = "rules" }, + new ChatMessage { Role = "user", Content = "status?" }, + }, + }; + + string adapter = OpenAIProtocolAdapter.BuildRequestJson(envelope, Node()); + Assert.Equal(Core(envelope), adapter); + + var parsed = JObject.Parse(adapter); + Assert.Null(parsed["tools"]); + Assert.Null(parsed["tool_choice"]); + }), + ("without a token budget only max_tokens differs: ModelService omits it instead of sending 0", () => + { + var envelope = new LlmRequestEnvelope + { + MaxTokens = 0, + Messages = new List { new ChatMessage { Role = "user", Content = "x" } }, + }; + + var adapter = JObject.Parse(OpenAIProtocolAdapter.BuildRequestJson(envelope, Node())); + var core = JObject.Parse(Core(envelope)); + Assert.Null(adapter["max_tokens"]); + Assert.Equal(0, core["max_tokens"]?.Value()); + core.Remove("max_tokens"); + Assert.True(JToken.DeepEquals(core, adapter), adapter.ToString()); + }), + ("a malformed tool schema degrades to an empty object schema instead of failing the request", () => + { + var envelope = new LlmRequestEnvelope + { + Messages = new List { new ChatMessage { Role = "user", Content = "x" } }, + Tools = new List { new StructuredTool { Name = "bad.tool", Description = "b", Parameters = "{not json" } }, + }; + + var parsed = JObject.Parse(OpenAIProtocolAdapter.BuildRequestJson(envelope, Node())); + Assert.Equal("bad_tool", parsed["tools"]?[0]?["function"]?["name"]?.ToString()); + Assert.Equal("object", parsed["tools"]?[0]?["function"]?["parameters"]?["type"]?.ToString()); + })); + } + + [Fact] + public void OpenAI_response_parsing_matches_core_client_semantics() + { + ContractCaseRunner.Run( + ("tool calls keep the wire (underscore) name for ToolRegistry and use Core's ToolCallsJson shape", () => + { + string raw = "{\"id\":\"chatcmpl-1\",\"choices\":[{\"finish_reason\":\"tool_calls\",\"message\":{\"role\":\"assistant\",\"content\":null," + + "\"tool_calls\":[{\"id\":\"call_9\",\"type\":\"function\",\"function\":{\"name\":\"actions_move_pawn\",\"arguments\":\"{\\\"x\\\":2}\"}}]}}]," + + "\"usage\":{\"prompt_tokens\":30,\"completion_tokens\":5,\"total_tokens\":35,\"prompt_tokens_details\":{\"cached_tokens\":24}}}"; + + var result = OpenAIProtocolAdapter.ParseResponse(raw, ToolLoopEnvelope()); + Assert.True(result.IsOk); + var calls = JArray.Parse(result.Value.ToolCallsJson!); + Assert.Single(calls); + Assert.Equal("call_9", calls[0]["id"]?.ToString()); + Assert.Equal("function", calls[0]["type"]?.ToString()); + Assert.Equal("actions_move_pawn", calls[0]["function"]?["name"]?.ToString()); + Assert.Equal("{\"x\":2}", calls[0]["function"]?["arguments"]?.ToString()); + Assert.Equal(string.Empty, result.Value.Content); + Assert.Equal(24, result.Value.CachedTokens); + Assert.Equal(35, result.Value.TokensUsed); + }), + ("no tool calls yields null ToolCallsJson (not an empty string)", () => + { + string raw = "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"hello\"}}]}"; + var result = OpenAIProtocolAdapter.ParseResponse(raw, null); + Assert.True(result.IsOk); + Assert.Null(result.Value.ToolCallsJson); + Assert.Null(result.Value.ReasoningContent); + Assert.Equal("hello", result.Value.Content); + }), + ("reasoning-only reply falls back to reasoning text when no tools were offered", () => + { + string raw = "{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":\"{\\\"a\\\":1}\"}}]}"; + var plain = new LlmRequestEnvelope { Messages = new List { new ChatMessage { Role = "user", Content = "q" } } }; + var result = OpenAIProtocolAdapter.ParseResponse(raw, plain); + Assert.True(result.IsOk); + Assert.Equal("{\"a\":1}", result.Value.Content); + }), + ("reasoning fallback is suppressed when tools were offered or the reply was truncated", () => + { + string stop = "{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":\"\",\"reasoning_content\":\"thinking\"}}]}"; + var withTools = OpenAIProtocolAdapter.ParseResponse(stop, ToolLoopEnvelope()); + Assert.Equal(string.Empty, withTools.Value.Content); + + string length = "{\"choices\":[{\"finish_reason\":\"length\",\"message\":{\"content\":\"\",\"reasoning_content\":\"thinking\"}}]}"; + var truncated = OpenAIProtocolAdapter.ParseResponse(length, new LlmRequestEnvelope()); + Assert.Equal(string.Empty, truncated.Value.Content); + Assert.Equal("thinking", truncated.Value.ReasoningContent); + }), + ("provider error messages are redacted before they become RimMindErrors", () => + { + string raw = "{\"error\":{\"message\":\"Incorrect API key provided: sk-proj-abcdefghijklmnop. Authorization: Bearer abcdefghijkl\"}}"; + var result = OpenAIProtocolAdapter.ParseResponse(raw, null); + Assert.True(result.IsErr); + Assert.DoesNotContain("sk-proj-abcdefghijklmnop", result.Error.Message); + Assert.DoesNotContain("abcdefghijkl", result.Error.Message); + })); + } + + [Fact] + public void Anthropic_request_uses_provider_safe_tool_names_and_required_choice() + { + ContractCaseRunner.Run( + ("dotted tool ids are sent in underscore form and required maps to tool_choice any", () => + { + var parsed = JObject.Parse(AnthropicProtocolAdapter.BuildRequestJson(ToolLoopEnvelope(), Node())); + var tools = (JArray)parsed["tools"]!; + Assert.Equal("actions_move_pawn", tools[0]["name"]?.ToString()); + Assert.Equal("core_query", tools[1]["name"]?.ToString()); + Assert.Equal("any", parsed["tool_choice"]?["type"]?.ToString()); + }), + ("few-shot examples precede the live turns and the tool loop stays paired", () => + { + var parsed = JObject.Parse(AnthropicProtocolAdapter.BuildRequestJson(ToolLoopEnvelope(), Node())); + var messages = (JArray)parsed["messages"]!; + Assert.Equal("EU", messages[0]["content"]?[0]?["text"]?.ToString()); + Assert.Equal("assistant", messages[1]["role"]?.ToString()); + Assert.Equal("EA", messages[1]["content"]?[0]?["text"]?.ToString()); + Assert.Equal("U1", messages[2]["content"]?[0]?["text"]?.ToString()); + Assert.Equal("tool_use", messages[3]["content"]?[0]?["type"]?.ToString()); + Assert.Equal("call_1", messages[4]["content"]?[0]?["tool_use_id"]?.ToString()); + }), + ("no tool_choice is sent unless a tool requires it, and empty tool calls parse to null", () => + { + var envelope = new LlmRequestEnvelope + { + Messages = new List { new ChatMessage { Role = "user", Content = "x" } }, + Tools = new List { new StructuredTool { Name = "a.b", Description = "d" } }, + }; + var parsed = JObject.Parse(AnthropicProtocolAdapter.BuildRequestJson(envelope, Node())); + Assert.Null(parsed["tool_choice"]); + + var response = AnthropicProtocolAdapter.ParseResponse("{\"content\":[{\"type\":\"text\",\"text\":\"hi\"}]}"); + Assert.True(response.IsOk); + Assert.Null(response.Value.ToolCallsJson); + })); + } + + [Fact] + public void SecretRedactor_matches_core_log_sanitizer() + { + var corpus = new[] + { + "Authorization: Bearer abcdefghijklmnop", + "Basic dXNlcjpwYXNzd29yZA==", + "{\"api_key\":\"plainvalue123\",\"model\":\"x\"}", + "x-api-key=gatewaytoken42&foo=bar", + "refresh_token: r3fr3sh", + "password=hunter2;", + "keys sk-proj-1234567890ab oc_sk_abcdefghijk sk-ant-api03-abcdefghij AIzaSyA1234567890abcdef gsk_abcdefghijklmn xai-abcdefghijkl", + "monkey=banana token_count=12 tokenizer=cl100k", + "", + "nothing secret here", + }; + + ContractCaseRunner.Run( + ("every corpus line redacts exactly like Core's LogSanitizer", () => + { + foreach (var line in corpus) + Assert.Equal(LogSanitizer.Redact(line), SecretRedactor.Redact(line)); + }), + ("shapes the previous ModelService regex leaked are now masked", () => + { + Assert.DoesNotContain("plainvalue123", SecretRedactor.Redact(corpus[2])); + Assert.DoesNotContain("gatewaytoken42", SecretRedactor.Redact(corpus[3])); + Assert.DoesNotContain("dXNlcjpwYXNzd29yZA", SecretRedactor.Redact(corpus[1])); + Assert.DoesNotContain("gsk_abcdefghijklmn", SecretRedactor.Redact(corpus[6])); + }), + ("the configured node key is masked literally even when it matches no generic shape", () => + { + string message = SecretRedactor.ForMessageWithSecret("connect failed for http://gw/?k=Zq9Wm4", "Zq9Wm4"); + Assert.DoesNotContain("Zq9Wm4", message); + }), + ("messages are truncated with Core's length marker after redaction", () => + { + string longText = new string('a', 600); + Assert.Equal(LogSanitizer.ForLog(longText), SecretRedactor.ForMessage(longText)); + })); + } + } +} diff --git a/Tests/RimMindModelService.Tests.csproj b/Tests/RimMindModelService.Tests.csproj index c5ecb72..3058bb2 100644 --- a/Tests/RimMindModelService.Tests.csproj +++ b/Tests/RimMindModelService.Tests.csproj @@ -33,7 +33,10 @@ + + + @@ -42,4 +45,12 @@ + + + + + + + diff --git a/Tests/VerseStubs.cs b/Tests/VerseStubs.cs index c57eb2d..c76178a 100644 --- a/Tests/VerseStubs.cs +++ b/Tests/VerseStubs.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace Verse @@ -20,11 +21,70 @@ public enum LookMode Reference } + public enum LoadSaveMode + { + Inactive, + Saving, + LoadingVars, + ResolvingCrossRefs, + PostLoadInit + } + + public static class Scribe + { + [ThreadStatic] + private static LoadSaveMode _mode; + + public static LoadSaveMode mode + { + get => _mode; + set => _mode = value; + } + } + public static class Scribe_Values { + [ThreadStatic] + private static Dictionary? _activeBuffer; + + public static void BeginSave(Dictionary buffer) + { + _activeBuffer = buffer ?? throw new ArgumentNullException(nameof(buffer)); + Scribe.mode = LoadSaveMode.Saving; + } + + public static void BeginLoad(Dictionary buffer, LoadSaveMode loadMode = LoadSaveMode.LoadingVars) + { + _activeBuffer = buffer ?? throw new ArgumentNullException(nameof(buffer)); + Scribe.mode = loadMode; + } + + public static void End() + { + _activeBuffer = null; + Scribe.mode = LoadSaveMode.Inactive; + } + public static void Look(ref T value, string label, T defaultValue = default!, bool forceSave = false) { - // Stub for test execution + if (_activeBuffer == null || Scribe.mode == LoadSaveMode.Inactive) + return; + + if (Scribe.mode == LoadSaveMode.Saving) + { + _activeBuffer[label] = value; + } + else if (Scribe.mode == LoadSaveMode.LoadingVars) + { + if (_activeBuffer.TryGetValue(label, out var raw) && raw is T typed) + { + value = typed; + } + else + { + value = defaultValue; + } + } } }