Add OpenRouter chat provider; fix Windows load errors (0.17.0).

- OpenRouter as an alternative chat provider: server-side API key
  (settings.json or OPENROUTER_API_KEY), model list with vision marks and
  filter, SSE streaming, images as data URLs. Memory embeddings stay on Ollama;
  park/warm LLM are no-ops for the remote provider.
- AssistentSaveKnowledgeAttach: JArray param is not supported by SwarmUI API
  reflection and aborted registration of every later API call; use string[].
- SQLite bootstrap: preload native e_sqlite3 for the current OS/arch
  (win/osx/linux) and resolve DllImport to it.
- settings.json: shared-read + atomic write with retry to avoid Windows
  sharing violations.
- csproj: fix MSB4012 in the native SQLite copy target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nid
2026-09-17 06:18:47 +03:00
co-authored by Claude Opus 5
parent f2b92e96ca
commit d67f5e396e
13 changed files with 740 additions and 56 deletions
+307
View File
@@ -0,0 +1,307 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>OpenRouter chat transport (OpenAI-compatible /chat/completions). Chat only —
/// memory embeddings stay on Ollama. The API key lives server-side in settings.json
/// (or OPENROUTER_API_KEY) and is never sent back to the browser.</summary>
public partial class SwarmAssistentExtension
{
const string OpenRouterRoot = "https://openrouter.ai/api/v1";
const string ProviderOllama = "ollama";
const string ProviderOpenRouter = "openrouter";
string ChatProvider()
{
string p = Config?.LoadSettings()["provider"]?.ToString()?.Trim().ToLowerInvariant();
return p == ProviderOpenRouter ? ProviderOpenRouter : ProviderOllama;
}
bool UseOpenRouter() => ChatProvider() == ProviderOpenRouter;
string ProviderLabel() => UseOpenRouter() ? "OpenRouter" : "Ollama";
string OpenRouterKey()
{
string key = Config?.LoadSettings()["openrouter_api_key"]?.ToString()?.Trim();
return string.IsNullOrWhiteSpace(key) ? Environment.GetEnvironmentVariable("OPENROUTER_API_KEY")?.Trim() ?? "" : key;
}
/// <summary>Provider state for the settings pane. Only a masked tail of the key is exposed.</summary>
public async Task<JObject> AssistentGetProvider(Session session)
{
await Task.CompletedTask;
string key = OpenRouterKey();
return new JObject
{
["success"] = true,
["provider"] = ChatProvider(),
["openrouter_key_set"] = key.Length > 0,
["openrouter_key_hint"] = key.Length > 8 ? $"…{key[^4..]}" : "",
["openrouter_model"] = Config.LoadSettings()["openrouter_model"]?.ToString() ?? "",
};
}
/// <summary>Sets provider / key / model. Null fields are left untouched; an empty <paramref name="api_key"/> clears the key.</summary>
public async Task<JObject> AssistentSetProvider(Session session, string provider = null, string api_key = null, string model = null)
{
await Task.CompletedTask;
JObject patch = [];
if (provider is not null)
{
string p = provider.Trim().ToLowerInvariant();
if (p != ProviderOllama && p != ProviderOpenRouter)
{
return new JObject { ["error"] = "provider must be ollama or openrouter" };
}
patch["provider"] = p;
}
if (api_key is not null)
{
patch["openrouter_api_key"] = api_key.Trim();
}
if (model is not null)
{
patch["openrouter_model"] = model.Trim();
}
Config.SaveSettings(patch);
return await AssistentGetProvider(session);
}
HttpRequestMessage OpenRouterRequest(HttpMethod method, string path, HttpContent content = null)
{
HttpRequestMessage req = new(method, $"{OpenRouterRoot}{path}") { Content = content };
string key = OpenRouterKey();
if (key.Length > 0)
{
req.Headers.TryAddWithoutValidation("Authorization", $"Bearer {key}");
}
req.Headers.TryAddWithoutValidation("HTTP-Referer", "https://github.com/mcmonkeyprojects/SwarmUI");
req.Headers.TryAddWithoutValidation("X-Title", "SwarmUI Assistent");
return req;
}
/// <summary>OpenRouter chat models + Ollama embed models (best effort) for memory.</summary>
async Task<JObject> ListOpenRouterModels(string ollamaRoot)
{
if (OpenRouterKey().Length == 0)
{
return new JObject { ["error"] = "OpenRouter: API key not set" };
}
JArray models = [];
JArray visionModels = [];
try
{
using HttpRequestMessage req = OpenRouterRequest(HttpMethod.Get, "/models");
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
string body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
return new JObject { ["error"] = $"OpenRouter /models HTTP {(int)resp.StatusCode}: {Clip(body, 400)}" };
}
IEnumerable<JObject> entries = (JObject.Parse(body)["data"] as JArray ?? []).OfType<JObject>()
.Where(m => !string.IsNullOrWhiteSpace(m["id"]?.ToString()))
.Where(m => (m["architecture"]?["output_modalities"] as JArray)?.Any(t => t.ToString() == "text") ?? true)
.OrderBy(m => m["id"].ToString(), StringComparer.OrdinalIgnoreCase);
foreach (JObject m in entries)
{
string id = m["id"].ToString();
models.Add(id);
if ((m["architecture"]?["input_modalities"] as JArray)?.Any(t => t.ToString() == "image") == true)
{
visionModels.Add(id);
}
}
}
catch (Exception ex)
{
return new JObject { ["error"] = $"OpenRouter unreachable: {ex.Message}" };
}
JArray memoryModels = [];
JObject ollama = await ListOllamaModels(ollamaRoot);
if (ollama["memory_models"] is JArray mem)
{
memoryModels = mem;
}
string preferred = Config.LoadSettings()["openrouter_model"]?.ToString()?.Trim() ?? "";
return new JObject
{
["success"] = true,
["provider"] = ProviderOpenRouter,
["base_url"] = OpenRouterRoot,
["models"] = models,
["vision_models"] = visionModels,
["memory_models"] = memoryModels,
["memory_error"] = ollama["error"],
["preferred"] = models.Any(t => t.ToString() == preferred) ? preferred : "",
};
}
/// <summary>Ollama-style messages (<c>images</c> = raw base64) → OpenAI content parts.</summary>
static JArray ToOpenAiMessages(List<JObject> ollamaMessages)
{
JArray result = [];
foreach (JObject m in ollamaMessages)
{
string role = m["role"]?.ToString() ?? "user";
string text = m["content"]?.ToString() ?? "";
if (m["images"] is JArray images && images.Count > 0)
{
JArray parts = [];
if (text.Length > 0)
{
parts.Add(new JObject { ["type"] = "text", ["text"] = text });
}
foreach (JToken img in images)
{
string s = img.ToString();
string url = s.StartsWith("data:", StringComparison.OrdinalIgnoreCase) || s.StartsWith("http", StringComparison.OrdinalIgnoreCase)
? s
: $"data:image/jpeg;base64,{s}";
parts.Add(new JObject { ["type"] = "image_url", ["image_url"] = new JObject { ["url"] = url } });
}
result.Add(new JObject { ["role"] = role, ["content"] = parts });
}
else
{
result.Add(new JObject { ["role"] = role, ["content"] = text });
}
}
return result;
}
/// <summary>Copies OpenAI usage into the Ollama field names the pipeline / UI already read.</summary>
static void MapUsage(JObject raw, JToken usage)
{
if (usage is JObject u && u["prompt_tokens"]?.Value<int?>() is int prompt && prompt > 0)
{
raw["prompt_eval_count"] = prompt;
}
}
async Task<(string reply, JObject raw)> CallOpenRouterChat(
string modelName,
List<JObject> ollamaMessages,
bool stream,
Func<string, Task> onDelta,
string personaId = null)
{
if (OpenRouterKey().Length == 0)
{
throw new Exception("OpenRouter API key not set (⚙ → Модели)");
}
int numPredict = Config.LoadAssistant(AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId())["num_predict"]?.Value<int?>()
?? 3072;
if (numPredict < 512)
{
numPredict = 512;
}
JObject payload = new()
{
["model"] = modelName,
["stream"] = stream,
["messages"] = ToOpenAiMessages(ollamaMessages),
["max_tokens"] = numPredict,
["usage"] = new JObject { ["include"] = true },
};
using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
using HttpRequestMessage req = OpenRouterRequest(HttpMethod.Post, "/chat/completions", content);
using HttpResponseMessage resp = await HttpClient.SendAsync(req, stream
? HttpCompletionOption.ResponseHeadersRead
: HttpCompletionOption.ResponseContentRead);
if (!resp.IsSuccessStatusCode)
{
string errBody = await resp.Content.ReadAsStringAsync();
throw new Exception($"OpenRouter HTTP {(int)resp.StatusCode}: {Clip(errBody, 800)}");
}
if (!stream)
{
string body = await resp.Content.ReadAsStringAsync();
JObject parsed = JObject.Parse(body);
if (parsed["error"] is JToken err)
{
throw new Exception($"OpenRouter: {Clip(err["message"]?.ToString() ?? err.ToString(), 800)}");
}
MapUsage(parsed, parsed["usage"]);
string reply = parsed["choices"]?[0]?["message"]?["content"]?.ToString() ?? "";
if (TryTruncateAtCompleteFence(reply, out string cut))
{
reply = cut;
}
return (reply, parsed);
}
StringBuilder full = new();
JObject last = [];
await using Stream streamBody = await resp.Content.ReadAsStreamAsync();
using StreamReader reader = new(streamBody, Encoding.UTF8);
while (true)
{
string line = await reader.ReadLineAsync();
if (line is null)
{
break;
}
// SSE: skip blanks and ": OPENROUTER PROCESSING" keep-alive comments.
if (!line.StartsWith("data:", StringComparison.Ordinal))
{
continue;
}
string data = line[5..].Trim();
if (data == "[DONE]")
{
break;
}
JObject chunk;
try
{
chunk = JObject.Parse(data);
}
catch (Exception)
{
continue;
}
if (chunk["error"] is JToken err)
{
throw new Exception($"OpenRouter: {Clip(err["message"]?.ToString() ?? err.ToString(), 800)}");
}
last["id"] = chunk["id"];
last["model"] = chunk["model"];
MapUsage(last, chunk["usage"]);
string delta = chunk["choices"]?[0]?["delta"]?["content"]?.ToString() ?? "";
if (string.IsNullOrEmpty(delta))
{
continue;
}
full.Append(delta);
// Same early stop as the Ollama path: closed ```json``` patch → stop reading.
if (TryTruncateAtCompleteFence(full.ToString(), out string cut))
{
string extra = full.Length > cut.Length ? full.ToString(cut.Length, full.Length - cut.Length) : "";
full.Clear();
full.Append(cut);
int keep = delta.Length - extra.Length;
if (onDelta is not null && keep > 0)
{
await onDelta(delta[..keep]);
}
break;
}
if (onDelta is not null)
{
await onDelta(delta);
}
}
return (full.ToString(), last);
}
}