Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion Source/Client/EnvironmentEndpointOverlay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Concurrent;
using System.Collections.Generic;
using RimMind.ModelService.Models;
using RimMind.ModelService.Protocol;

namespace RimMind.ModelService.Client
{
Expand Down Expand Up @@ -64,7 +65,8 @@ public IReadOnlyList<ModelEndpointConfig> Apply(IReadOnlyList<ModelEndpointConfi
{
if (ep == null)
continue;
if (!string.IsNullOrEmpty(ep.apiKey) || ep.providerType != ProviderType.OpenCodeGo)
if (!string.IsNullOrEmpty(ep.apiKey)
|| !string.Equals(ep.EffectiveProtocolId, ModelProtocolRegistry.OpenCodeGoProtocolId, StringComparison.OrdinalIgnoreCase))
{
result.Add(ep);
continue;
Expand All @@ -74,8 +76,10 @@ public IReadOnlyList<ModelEndpointConfig> Apply(IReadOnlyList<ModelEndpointConfi
shadow.id = ep.id ?? string.Empty;
shadow.name = ep.name;
shadow.providerType = ep.providerType;
shadow.protocolId = ep.EffectiveProtocolId;
shadow.priority = ep.priority;
shadow.weight = ep.weight;
shadow.timeoutSeconds = ep.timeoutSeconds;
shadow.isEnabled = ep.isEnabled;
shadow.apiKey = _apiKey;
shadow.endpoint = string.IsNullOrEmpty(_endpoint) ? ep.endpoint : _endpoint!;
Expand Down
92 changes: 23 additions & 69 deletions Source/Client/ModelServiceClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using RimMind.Application.Common.Interfaces.Client;
Expand All @@ -24,10 +23,6 @@ public class ModelServiceClient : IAIClient
private const int MaxErrorBodyChars = 500;
private const int DefaultOverallTimeoutSeconds = 180;

private static readonly Regex SecretRedactionRegex = new Regex(
@"(?i)(?:Bearer\s+[A-Za-z0-9\-\._~\+\/=]{4,}|\b(?:sk|oc_sk|sk-ant|sk-proj)[\-_][A-Za-z0-9\-_]{8,})",
RegexOptions.Compiled);

private static readonly HttpClient HttpClient = new HttpClient(new HttpClientHandler
{
AllowAutoRedirect = false
Expand Down Expand Up @@ -67,13 +62,12 @@ internal ModelServiceClient(
_clockMs = clockMsOverride ?? (() => 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) =>
Expand Down Expand Up @@ -174,7 +168,8 @@ private async Task<Result<LlmResponse, RimMindError>> 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);
Expand All @@ -184,7 +179,7 @@ private async Task<Result<LlmResponse, RimMindError>> 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;
Expand Down Expand Up @@ -233,6 +228,7 @@ private async Task<Result<LlmResponse, RimMindError>> SendCoreAsync(
private async Task<Result<LlmResponse, RimMindError>> TrySendToNodeAsync(
LlmRequestEnvelope envelope,
ModelEndpointConfig node,
IModelProtocol protocol,
CancellationToken overallToken,
CancellationToken callerToken)
{
Expand All @@ -242,54 +238,19 @@ private async Task<Result<LlmResponse, RimMindError>> 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<LlmResponse, RimMindError>.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);
Expand All @@ -299,29 +260,22 @@ private async Task<Result<LlmResponse, RimMindError>> 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<LlmResponse, RimMindError>.Err(
(statusCode == 429 || statusCode >= 500 || statusCode == 408)
? RimMindErrors.ClientTransient(errMsg)
: RimMindErrors.ClientPermanent(errMsg));
}

Result<LlmResponse, RimMindError> 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<LlmResponse, RimMindError>.Ok(val);
}
Expand All @@ -342,7 +296,7 @@ private async Task<Result<LlmResponse, RimMindError>> TrySendToNodeAsync(
{
stopwatch.Stop();
return Result<LlmResponse, RimMindError>.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));
}
}

Expand Down
30 changes: 27 additions & 3 deletions Source/Client/ModelServiceClientFactory.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down
14 changes: 8 additions & 6 deletions Source/Client/OpenCodeGoClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -53,4 +56,3 @@ public IAIClient Create(ISettingsProvider settings)
}
}
}

Loading
Loading