From 985eb20a38f68bdd2d6398faca8e3fd4f4ac1238 Mon Sep 17 00:00:00 2001 From: retoor Date: Wed, 10 Sep 2025 11:02:48 +0200 Subject: [PATCH] Add api_client.cs --- api_client.cs | 626 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 626 insertions(+) create mode 100644 api_client.cs diff --git a/api_client.cs b/api_client.cs new file mode 100644 index 0000000..081fce0 --- /dev/null +++ b/api_client.cs @@ -0,0 +1,626 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; + +namespace AISApp +{ + // JSON DTOs for API + public sealed class ChatMessage + { + [JsonPropertyName("role")] public string Role { get; set; } = ""; + [JsonPropertyName("content")] public string Content { get; set; } = ""; + } + + public sealed class ChatRequest + { + [JsonPropertyName("model")] public string Model { get; set; } = ""; + [JsonPropertyName("messages")] public List Messages { get; set; } = new(); + [JsonPropertyName("temperature")] public double Temperature { get; set; } = 0.0; + } + + public sealed class ChatChoice + { + [JsonPropertyName("message")] public ChatMessage Message { get; set; } = new(); + } + + public sealed class ChatResponse + { + [JsonPropertyName("choices")] public List Choices { get; set; } = new(); + } + + public sealed class AIS + { + // -------- Static / constants -------- + private static readonly HttpClient Http = new HttpClient(); + private static readonly JsonSerializerOptions JsonOpts = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + WriteIndented = false + }; + + public static readonly Dictionary PredefinedModels = new(StringComparer.OrdinalIgnoreCase) + { + ["dobby"] = "sentientagi/dobby-mini-unhinged-plus-llama-3.1-8b", + ["dolphin"] = "cognitivecomputations/dolphin-mixtral-8x22b", + ["dolphin_free"] = "cognitivecomputations/dolphin3.0-mistral-24b:free", + ["gemma"] = "google/gemma-3-12b-it", + ["gpt-4o-mini"] = "openai/gpt-4o-mini", + ["gpt-4.1-nano"] = "openai/gpt-4.1-nano", + ["qwen"] = "qwen/qwen3-30b-a3b", + ["unslop"] = "thedrummer/unslopnemo-12b", + ["euryale"] = "sao10k/l3.3-euryale-70b", + ["wizard"] = "microsoft/wizardlm-2-8x22b", + ["deepseek"] = "deepseek/deepseek-chat-v3-0324" + }; + + // -------- Instance state (mirrors Python) -------- + public string Name { get; private set; } + public double Temperature { get; private set; } + public double Backoff { get; private set; } + public int MaxRetries { get; private set; } + public bool SafeMode { get; private set; } + public string SystemMessage { get; private set; } + public string Model { get; private set; } + public string BaseUrl { get; private set; } + public string Rel { get; private set; } + public string ApiKey { get; private set; } + public string DbName { get; private set; } + public bool Loaded { get; private set; } + + private readonly List _messages = new(); + private readonly SqliteConnection _conn; + + private Dictionary Headers => new() + { + ["Authorization"] = $"Bearer {ApiKey}", + ["Content-Type"] = "application/json" + }; + + // -------- ctor -------- + public AIS( + string? systemMessage = null, + string model = "gemma", + int maxRetries = 3, + double backoff = 1, + string? name = null, + double temperature = 0.0, + string dbName = ":memory:", + bool safeMode = true, + string baseUrl = "openrouter.ai", + string rel = "/api", + string? apiKey = null + ) + { + Loaded = dbName != ":memory:" && File.Exists(dbName); + DbName = dbName; + + // Open connection and create schema + _conn = OpenConnection(dbName); + InitDb(); + + // If existing DB, hydrate settings first + if (Loaded) + { + temperature = GetSetting("temperature", temperature); + name = GetSetting("name", name ?? ""); + backoff = GetSetting("backoff", backoff); + maxRetries = GetSetting("max_retries", maxRetries); + safeMode = GetSetting("safe_mode", safeMode); + systemMessage = GetSetting("system_message", systemMessage ?? ""); + model = GetSetting("model", model); + } + + if (string.IsNullOrEmpty(systemMessage)) + { + systemMessage = "You respond like a calculator; only the result is the answer. Respond only with the result. No explanations or extra words."; + } + + SystemMessage = systemMessage; + Name = !string.IsNullOrEmpty(name) + ? $"ais_{name}" + : $"ais_{Guid.NewGuid().ToString("N").Substring(0, 8)}"; + + Backoff = backoff; + SafeMode = safeMode; + MaxRetries = maxRetries; + BaseUrl = baseUrl; + Rel = rel; + ApiKey = apiKey ?? Environment.GetEnvironmentVariable("OPENROUTER_API_KEY") + ?? "sk-or-REPLACE_ME"; // keep explicit default like Python had + Temperature = temperature; + Model = PredefinedModels.TryGetValue(model, out var mapped) ? mapped : model; + + if (MessagesExist()) + { + _messages = LoadMessages(); + } + else + { + _messages.Add(new ChatMessage { Role = "system", Content = SystemMessage }); + SaveMessage("system", SystemMessage); + } + + Console.WriteLine($"[INFO] Initialized AIS name={Name} model={Model} db={DbName}"); + } + + // -------- DB helpers -------- + private static SqliteConnection OpenConnection(string dbName) + { + var cs = dbName == ":memory:" ? "Data Source=:memory:" : $"Data Source={dbName}"; + var conn = new SqliteConnection(cs); + conn.Open(); + return conn; + } + + private void InitDb() + { + using var cmd1 = _conn.CreateCommand(); + cmd1.CommandText = @" + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role TEXT NOT NULL, + content TEXT NOT NULL + )"; + cmd1.ExecuteNonQuery(); + + using var cmd2 = _conn.CreateCommand(); + cmd2.CommandText = @" + CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + json_value TEXT NOT NULL + )"; + cmd2.ExecuteNonQuery(); + } + + private bool MessagesExist() + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM messages"; + var count = Convert.ToInt32(cmd.ExecuteScalar()); + return count > 0; + } + + private List LoadMessages() + { + var list = new List(); + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "SELECT role, content FROM messages ORDER BY id ASC"; + using var rdr = cmd.ExecuteReader(); + while (rdr.Read()) + { + list.Add(new ChatMessage + { + Role = rdr.GetString(0), + Content = rdr.GetString(1) + }); + } + return list; + } + + private void SaveMessage(string role, string content) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "INSERT INTO messages (role, content) VALUES ($r, $c)"; + cmd.Parameters.AddWithValue("$r", role); + cmd.Parameters.AddWithValue("$c", content); + cmd.ExecuteNonQuery(); + } + + // Generic typed setting helpers + private T GetSetting(string name, T defaultValue) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "SELECT json_value FROM settings WHERE name = $n LIMIT 1"; + cmd.Parameters.AddWithValue("$n", name); + var obj = cmd.ExecuteScalar(); + if (obj is string s) + { + try + { + var node = JsonNode.Parse(s); + if (node is null) return defaultValue!; + return node.Deserialize(JsonOpts)!; + } + catch + { + return defaultValue!; + } + } + return defaultValue!; + } + + private void SetSetting(string name, object? value) + { + var json = JsonSerializer.Serialize(value, JsonOpts); + + using (var upd = _conn.CreateCommand()) + { + upd.CommandText = "UPDATE settings SET json_value = $v WHERE name = $n"; + upd.Parameters.AddWithValue("$v", json); + upd.Parameters.AddWithValue("$n", name); + var rows = upd.ExecuteNonQuery(); + if (rows == 0) + { + using var ins = _conn.CreateCommand(); + ins.CommandText = "INSERT INTO settings (name, json_value) VALUES ($n, $v)"; + ins.Parameters.AddWithValue("$n", name); + ins.Parameters.AddWithValue("$v", json); + ins.ExecuteNonQuery(); + } + } + + // Reflectively update instance if property exists (case-insensitive) + var prop = GetType().GetProperty(name, System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.IgnoreCase); + if (prop != null && prop.CanWrite) + { + try + { + var targetVal = value; + if (value is JsonNode node) + { + targetVal = node.Deserialize(prop.PropertyType, JsonOpts); + } + else if (value is string str && prop.PropertyType != typeof(string)) + { + try + { + var jn = JsonNode.Parse(str); + if (jn != null) targetVal = jn.Deserialize(prop.PropertyType, JsonOpts); + } + catch { /* ignore */ } + } + prop.SetValue(this, ConvertIfNeeded(targetVal, prop.PropertyType)); + } + catch { /* ignore */ } + } + } + + private static object? ConvertIfNeeded(object? value, Type targetType) + { + if (value == null) return null; + if (targetType.IsAssignableFrom(value.GetType())) return value; + try { return Convert.ChangeType(value, targetType); } + catch { return value; } + } + + private void SaveSettings() + { + SetSetting("name", Name); + SetSetting("temperature", Temperature); + SetSetting("backoff", Backoff); + SetSetting("max_retries", MaxRetries); + SetSetting("safe_mode", SafeMode); + SetSetting("system_message", SystemMessage); + SetSetting("model", Model); + } + + // -------- Public API (mirrors Python) -------- + public void Append(string messageUser, string messageAssistant, bool save = true) + { + _messages.Add(new ChatMessage { Role = "user", Content = messageUser }); + if (save) SaveMessage("user", messageUser); + _messages.Add(new ChatMessage { Role = "assistant", Content = messageAssistant }); + if (save) SaveMessage("assistant", messageAssistant); + } + + public void Save(string dbPath) + { + if (DbName != ":memory:") + { + Console.WriteLine("[INFO] Already using persistent database."); + return; + } + var dbFile = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db"; + + using var dest = new SqliteConnection($"Data Source={dbFile}"); + dest.Open(); + _conn.BackupDatabase(dest); + dest.Close(); + + DbName = dbFile; + Console.WriteLine($"[INFO] Converted in-memory DB to persistent DB at {DbName}"); + } + + public void PatchTime() + { + const string timePrefix = "Current time: `"; + if (_messages.Count == 0) return; + + var content = _messages[0].Content ?? ""; + var nowStr = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); + + if (content.StartsWith(timePrefix, StringComparison.Ordinal)) + { + var end = content.IndexOf('`', timePrefix.Length); + if (end >= 0) + { + var rest = content.Substring(end + 1).TrimStart('\n'); + _messages[0].Content = $"{timePrefix}{nowStr}`\n\n{rest}"; + } + else + { + _messages[0].Content = $"{timePrefix}{nowStr}`\n\n{content}"; + } + } + else + { + _messages[0].Content = $"{timePrefix}{nowStr}`\n\n{content}"; + } + + // Persist system message update + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "UPDATE messages SET content = $c WHERE id = (SELECT MIN(id) FROM messages)"; + cmd.Parameters.AddWithValue("$c", _messages[0].Content); + cmd.ExecuteNonQuery(); + } + + public async Task ChatAsync(string message, double? temperature = null, CancellationToken ct = default) + { + if (!SafeMode) + { + // command handling + if (message.StartsWith("!reset", StringComparison.OrdinalIgnoreCase)) { Reset(); return "true"; } + if (message.StartsWith("!instruct", StringComparison.OrdinalIgnoreCase)) { Instruct(message.Length >= 10 ? message[10..] : ""); return "true"; } + if (message.StartsWith("!undo", StringComparison.OrdinalIgnoreCase)) { return Undo().ToString().ToLowerInvariant(); } + if (message.StartsWith("!get", StringComparison.OrdinalIgnoreCase)) { var key = message[4..].Trim(); return GetRawSettingOr("not found", key); } + if (message.StartsWith("!set", StringComparison.OrdinalIgnoreCase)) + { + var rest = message.Length >= 5 ? message[5..] : ""; + var idx = rest.IndexOf(' '); + if (idx > 0) + { + var nm = rest[..idx].Trim(); + var val = rest[(idx + 1)..].Trim(); + SetSetting(nm, TryJson(val)); + return "true"; + } + } + if (message.StartsWith("!query", StringComparison.OrdinalIgnoreCase)) { return Query(message.Length >= 6 ? message[6..] : ""); } + if (message.StartsWith("!edit", StringComparison.OrdinalIgnoreCase)) { Edit(message.Length >= 5 ? message[5..] : ""); return SystemMessage; } + if (message.StartsWith("!append", StringComparison.OrdinalIgnoreCase)) + { + var rest = message.Length >= 7 ? message[7..] : ""; + var firstColon = rest.IndexOf(':'); + if (firstColon >= 0 && rest.IndexOf(':', firstColon + 1) < 0) + { + var user = rest[..firstColon]; + var assistant = rest[(firstColon + 1)..]; + Append(user, assistant); + return "true"; + } + } + } + + PatchTime(); + + _messages.Add(new ChatMessage { Role = "user", Content = message }); + SaveMessage("user", message); + + var payload = new ChatRequest + { + Model = Model, + Messages = _messages, + Temperature = temperature ?? Temperature + }; + + var json = JsonSerializer.Serialize(payload, JsonOpts); + var url = $"https://{BaseUrl}{Rel}/v1/chat/completions"; + Console.WriteLine(url); + + int attempt = 0; + string assistant = "ERROR"; + double backoff = Backoff; + + while (attempt < MaxRetries) + { + try + { + using var req = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + foreach (var kv in Headers) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value); + + using var resp = await Http.SendAsync(req, ct); + var text = await resp.Content.ReadAsStringAsync(ct); + + var parsed = JsonSerializer.Deserialize(text, JsonOpts); + assistant = parsed?.Choices?[0]?.Message?.Content ?? "ERROR"; + + SaveSettings(); + + _messages.Add(new ChatMessage { Role = "assistant", Content = assistant }); + SaveMessage("assistant", assistant); + + return TryJson(assistant); + } + catch (Exception ex) + { + attempt++; + Console.WriteLine($"[WARN] Chat attempt {attempt} failed: {ex.Message}"); + if (attempt < MaxRetries) + { + await Task.Delay(TimeSpan.FromSeconds(backoff), ct); + backoff *= 2; + } + else + { + _messages.Add(new ChatMessage { Role = "assistant", Content = "ERROR" }); + SaveMessage("assistant", "ERROR"); + Console.WriteLine($"[CRITICAL] Failed after {MaxRetries} attempts."); + return "ERROR"; + } + } + } + + return "ERROR"; + } + + public object Chat(string message, double? temperature = null) + => ChatAsync(message, temperature).GetAwaiter().GetResult(); + + // JSON helper that mirrors Python _try_json (also strips ``` fences) + private static object TryJson(string response) + { + var fixedStr = (response ?? "").Trim(); + if (fixedStr.StartsWith("```", StringComparison.Ordinal) && fixedStr.EndsWith("```", StringComparison.Ordinal)) + { + var lines = fixedStr.Split('\n'); + if (lines.Length >= 2) + { + fixedStr = string.Join("\n", lines, 1, lines.Length - 2).Trim(); + } + } + + try + { + var node = JsonNode.Parse(fixedStr); + return node ?? response; + } + catch + { + return response; + } + } + + private object GetRawSettingOr(string fallback, string key) + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "SELECT json_value FROM settings WHERE name = $n LIMIT 1"; + cmd.Parameters.AddWithValue("$n", key); + var obj = cmd.ExecuteScalar(); + return obj is string s ? s : fallback; + } + + public void Reset() + { + Console.WriteLine("[INFO] Reset conversation history (keeping system message)."); + ChatMessage sys = _messages.Count > 0 ? _messages[0] : new ChatMessage { Role = "system", Content = "" }; + _messages.Clear(); + _messages.Add(sys); + + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "DELETE FROM messages WHERE id != (SELECT MIN(id) FROM messages)"; + cmd.ExecuteNonQuery(); + } + + public void Instruct(string instructions) + { + SystemMessage = instructions; + Console.WriteLine($"[INFO] System instructions updated."); + SaveSettings(); + + if (_messages.Count > 0) + { + _messages[0].Content = instructions; + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "UPDATE messages SET content = $c WHERE id = (SELECT MIN(id) FROM messages)"; + cmd.Parameters.AddWithValue("$c", instructions); + cmd.ExecuteNonQuery(); + } + else + { + _messages.Add(new ChatMessage { Role = "system", Content = instructions }); + SaveMessage("system", instructions); + } + Reset(); + } + + public object Query(string query) + { + var response = Chat(query); + Undo(); + return response; + } + + private bool DeleteLastMessage() + { + try + { + if (_messages.Count == 0) return false; + _messages.RemoveAt(_messages.Count - 1); + + using var cmd = _conn.CreateCommand(); + cmd.CommandText = "DELETE FROM messages WHERE id = (SELECT MAX(id) FROM messages)"; + cmd.ExecuteNonQuery(); + return true; + } + catch (Exception e) + { + Console.WriteLine($"[ERROR] Undo error: {e.Message}"); + return false; + } + } + + public bool Edit(string change) + { + var prompt = $"Apply this update: `{change}` to `{SystemMessage}` and return the result"; + var result = Query(prompt); + var newSys = result is JsonNode jn ? jn.ToJsonString() : result?.ToString() ?? SystemMessage; + Instruct(newSys); + return true; + } + + public bool Undo() + { + // delete assistant + user + return DeleteLastMessage() && DeleteLastMessage(); + } + + public void Repl() + { + Console.WriteLine("[INFO] REPL started. Ctrl+C to quit."); + while (true) + { + try + { + Console.Write("You: "); + var line = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(line)) continue; + + var response = Chat(line); + var typeName = response switch + { + JsonNode => "JsonNode", + null => "null", + _ => response.GetType().Name + }; + Console.WriteLine($"{Name}: {typeName} {FormatForPrint(response)}"); + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + Console.WriteLine($"[CRITICAL] Unexpected error: {ex.Message}"); + Console.WriteLine("AI: ERROR"); + } + } + } + + private static string FormatForPrint(object? obj) + { + if (obj is JsonNode jn) return jn.ToJsonString(new JsonSerializerOptions { WriteIndented = false }); + return obj?.ToString() ?? "null"; + } + } + + internal static class Program + { + // Equivalent to: if __name__ == '__main__': ai = AIS(model="wizard", safe_mode=False); ai.repl() + private static void Main(string[] args) + { + var ai = new AIS(model: "wizard", safeMode: false); + ai.Repl(); + } + } +}