{"repo": "alchuang22-dev/rimtalk-literature-expansion", "files": {"Source/synopsis/llm/IndependentBookLlmClient.cs": {"13fcd817a071f8fcd64ed4fbbe56ed062efff03e": {"t": 1786067640.4379392, "n": 32332, "h": "cae20655927a27b5", "c": "using System;\nusing System.Diagnostics;\nusing System.IO;\nusing System.Net;\nusing System.Reflection;\nusing System.Runtime.Serialization;\nusing System.Text;\nusing System.Text.RegularExpressions;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing RimTalk;\nusing RimTalk.Data;\nusing RimTalk.Util;\nusing RimTalk_LiteratureExpansion.settings;\nusing UnityEngine.Networking;\nusing Verse;\n\nnamespace RimTalk_LiteratureExpansion.synopsis.llm\n{\n public static class IndependentBookLlmClient\n {\n private const int TimeoutMs = 240000;\n private const int MinOutputTokens = 256;\n private const int MaxOutputTokensCap = 2048;\n private const int OutputTokenOverhead = 120;\n private const string Player2GameClientId = \"019a8368-b00b-72bc-b367-2825079dc6fb\";\n private const string Player2LocalBaseUrl = \"http://localhost:4315\";\n private const string Player2LocalHealthUrl = Player2LocalBaseUrl + \"/v1/health\";\n private const string Player2LocalLoginUrl = Player2LocalBaseUrl + \"/v1/login/web/\" + Player2GameClientId;\n private const int Player2LocalHealthTimeoutSeconds = 2;\n private const int Player2LocalLoginTimeoutSeconds = 3;\n private static string _player2LocalKey;\n private static DateTime _player2LocalKeyCheckedAt = DateTime.MinValue;\n private static readonly TimeSpan Player2LocalCheckInterval = TimeSpan.FromSeconds(30);\n private static int _requestId;\n private static readonly Regex OpenAIResponseRegex = new Regex(\n @\"\"\"content\"\"\\s*:\\s*\"\"((?:\\\\.|[^\"\"])*)\"\"\",\n RegexOptions.Compiled | RegexOptions.Singleline);\n private static readonly Regex GoogleResponseRegex = new Regex(\n @\"\"\"text\"\"\\s*:\\s*\"\"((?:\\\\.|[^\"\"])*)\"\"\",\n RegexOptions.Compiled | RegexOptions.Singleline);\n private static readonly Regex OpenAIFinishReasonRegex = new Regex(\n @\"\"\"finish_reason\"\"\\s*:\\s*\"\"([^\"\"]*)\"\"\",\n RegexOptions.Compiled | RegexOptions.Singleline);\n private static readonly Regex GoogleFinishReasonRegex = new Regex(\n @\"\"\"finishReason\"\"\\s*:\\s*\"\"([^\"\"]*)\"\"\",\n RegexOptions.Compiled | RegexOptions.Singleline);\n private static readonly Regex OpenAIUsageRegex = new Regex(\n @\"\"\"usage\"\"\\s*:\\s*\\{[^}]*?\"\"prompt_tokens\"\"\\s*:\\s*(\\d+)[^}]*?\"\"completion_tokens\"\"\\s*:\\s*(\\d+)[^}]*?\"\"total_tokens\"\"\\s*:\\s*(\\d+)\",\n RegexOptions.Compiled | RegexOptions.Singleline);\n private static readonly Regex GoogleUsageRegex = new Regex(\n @\"\"\"usageMetadata\"\"\\s*:\\s*\\{[^}]*?\"\"promptTokenCount\"\"\\s*:\\s*(\\d+)[^}]*?\"\"candidatesTokenCount\"\"\\s*:\\s*(\\d+)[^}]*?\"\"totalTokenCount\"\"\\s*:\\s*(\\d+)\",\n RegexOptions.Compiled | RegexOptions.Singleline);\n\n public static async Task QueryJsonAsync(TalkRequest request) where T : class, IJsonData\n {\n if (request == null) return null;\n\n int requestId = Interlocked.Increment(ref _requestId);\n\n if (!TryGetActiveConfig(out var config))\n {\n Log.Warning($\"[RimTalk LE] [Req {requestId}] No active RimTalk API config for independent book request.\");\n return null;\n }\n\n string model = ResolveModel(config);\n if (string.IsNullOrWhiteSpace(model))\n {\n Log.Warning($\"[RimTalk LE] [Req {requestId}] Missing model for independent book request.\");\n return null;\n }\n\n string apiKey = config.ApiKey;\n bool usePlayer2Local = false;\n\n if (config.Provider == AIProvider.Player2 && string.IsNullOrWhiteSpace(apiKey))\n {\n apiKey = await TryResolvePlayer2LocalKeyAsync(requestId);\n usePlayer2Local = !string.IsNullOrWhiteSpace(apiKey);\n if (!usePlayer2Local)\n {\n Log.Warning($\"[RimTalk LE] [Req {requestId}] Player2 API key is empty and no local app detected.\");\n return null;\n }\n }\n\n string endpoint = ResolveEndpoint(config, model);\n if (config.Provider == AIProvider.Player2 && usePlayer2Local)\n endpoint = $\"{Player2LocalBaseUrl}/v1/chat/completions\";\n if (string.IsNullOrWhiteSpace(endpoint))\n {\n Log.Warning($\"[RimTalk LE] [Req {requestId}] Missing endpoint for independent book request.\");\n return null;\n }\n\n try\n {\n Log.Message($\"[RimTalk LE] [Req {requestId}] Independent LLM request start.\");\n Log.Message($\"[RimTalk LE] [Req {requestId}] Provider: {config.Provider}, Model: {model}\");\n Log.Message($\"[RimTalk LE] [Req {requestId}] Endpoint: {SanitizeEndpoint(config.Provider, endpoint)}\");\n Log.Message($\"[RimTalk LE] [Req {requestId}] API key: {SanitizeApiKey(apiKey)}\");\n if (string.IsNullOrWhiteSpace(apiKey) && config.Provider != AIProvider.Google)\n Log.Warning($\"[RimTalk LE] [Req {requestId}] API key is empty.\");\n\n int maxTokens = ResolveMaxOutputTokens();\n Log.Message($\"[RimTalk LE] [Req {requestId}] Request max tokens: {maxTokens}\");\n string json = BuildRequestJson(config.Provider, model, request.Context, request.Prompt, maxTokens);\n Log.Message($\"[RimTalk LE] [Req {requestId}] Request payload length: {json?.Length ?? 0}\");\n\n string responseText = await PostJsonAsync(requestId, endpoint, json, apiKey, config.Provider);\n if (string.IsNullOrWhiteSpace(responseText))\n {\n Log.Warning($\"[RimTalk LE] [Req {requestId}] Empty response from independent book request.\");\n return null;\n }\n\n Log.Message($\"[RimTalk LE] [Req {requestId}] Response length: {responseText.Length}\");\n LogResponseMeta(config.Provider, responseText, requestId);\n string content = ExtractContent(config.Provider, responseText, requestId);\n if (string.IsNullOrWhiteSpace(content))\n {\n Log.Warning($\"[RimTalk LE] [Req {requestId}] Failed to parse response content from independent book request.\");\n return null;\n }\n\n var jsonPayload = ExtractJsonPayload(content);\n if (string.IsNullOrWhiteSpace(jsonPayload))\n {\n var trimmed = content.Trim();\n int startIndex = trimmed.IndexOf('{');\n int endIndex = trimmed.LastIndexOf('}');\n bool hasFence = trimmed.StartsWith(\"```\", StringComparison.Ordinal);\n Log.Warning($\"[RimTalk LE] [Req {requestId}] JSON detect: fence={hasFence}, start={startIndex}, end={endIndex}, len={trimmed.Length}\");\n Log.Warning($\"[RimTalk LE] [Req {requestId}] Response content was not JSON; abort deserialize.\");\n Log.Warning($\"[RimTalk LE] [Req {requestId}] Content preview: {TrimPreview(content)}\");\n return null;\n }\n\n Log.Message($\"[RimTalk LE] [Req {requestId}] Parsed JSON payload length: {jsonPayload.Length}\");\n var result = JsonUtil.DeserializeFromJson(jsonPayload);\n Log.Message($\"[RimTalk LE] [Req {requestId}] JSON deserialization {(result == null ? \"failed\" : \"succeeded\")}.\");\n return result;\n }\n catch (Exception ex)\n {\n Log.Warning($\"[RimTalk LE] [Req {requestId}] Independent book request failed: {ex.GetType().Name} - {ex.Message}\");\n return null;\n }\n }\n\n private static bool TryGetActiveConfig(out ApiConfig config)\n {\n config = null;\n var leSettings = LiteratureMod.Settings;\n if (leSettings != null && !leSettings.useRimTalkApi)\n {\n var api = leSettings.api;\n if (api == null)\n {\n Log.Warning(\"[RimTalk LE] Independent API settings are missing.\");\n return false;\n }\n\n if (string.IsNullOrWhiteSpace(api.baseUrl) ||\n string.IsNullOrWhiteSpace(api.model))\n {\n Log.Warning(\"[RimTalk LE] Independent API settings are incomplete (baseUrl/model).\");\n return false;\n }\n\n config = new ApiConfig\n {\n IsEnabled = true,\n Provider = AIProvider.Custom,\n ApiKey = api.apiKey ?? string.Empty,\n BaseUrl = api.baseUrl ?? string.Empty,\n CustomModelName = api.model ?? string.Empty,\n SelectedModel = \"Custom\"\n };\n\n if (!HasUsableEndpoint(config))\n {\n Log.Warning(\"[RimTalk LE] Independent API endpoint is missing.\");\n config = null;\n return false;\n }\n\n return true;\n }\n\n var settings = Settings.Get();\n if (settings == null) return false;\n\n if (settings.UseSimpleConfig)\n {\n if (!string.IsNullOrWhiteSpace(settings.SimpleApiKey))\n {\n config = new ApiConfig\n {\n ApiKey = settings.SimpleApiKey,\n Provider = AIProvider.Google,\n SelectedModel = settings.IsUsingFallbackModel ? RimTalkConstantShim.FallbackCloudModel : RimTalkConstantShim.DefaultCloudModel,\n IsEnabled = true\n };\n }\n return config != null;\n }\n\n if (settings.UseCloudProviders)\n {\n if (settings.CloudConfigs == null || settings.CloudConfigs.Count == 0) return false;\n\n for (int i = 0; i < settings.CloudConfigs.Count; i++)\n {\n int index = (settings.CurrentCloudConfigIndex + i) % settings.CloudConfigs.Count;\n var candidate = settings.CloudConfigs[index];\n if (candidate == null || !candidate.IsValid()) continue;\n if (!HasUsableEndpoint(candidate))\n {\n Log.Warning($\"[RimTalk LE] Skipping config without endpoint (provider={candidate.Provider}).\");\n continue;\n }\n\n settings.CurrentCloudConfigIndex = index;\n config = candidate;\n return true;\n }\n return false;\n }\n\n var localConfig = settings.LocalConfig;\n if (localConfig != null && localConfig.IsValid())\n {\n if (!HasUsableEndpoint(localConfig))\n {\n Log.Warning(\"[RimTalk LE] Local config missing Base URL for independent requests.\");\n return false;\n }\n config = localConfig;\n return true;\n }\n\n return false;\n }\n\n private static bool HasUsableEndpoint(ApiConfig config)\n {\n if (config == null) return false;\n\n if (config.Provider == AIProvider.Custom || config.Provider == AIProvider.Local)\n return !string.IsNullOrWhiteSpace(config.BaseUrl);\n\n if (config.Provider == AIProvider.Player2)\n return true;\n\n var endpoint = GetProviderEndpointUrl(config.Provider);\n return !string.IsNullOrWhiteSpace(endpoint);\n }\n\n private static string ResolveModel(ApiConfig config)\n {\n if (config == null) return string.Empty;\n\n if ((config.Provider == AIProvider.Local || config.Provider == AIProvider.Custom) &&\n !string.IsNullOrWhiteSpace(config.CustomModelName))\n return config.CustomModelName;\n\n if (string.Equals(config.SelectedModel, \"Custom\", StringComparison.OrdinalIgnoreCase) &&\n !string.IsNullOrWhiteSpace(config.CustomModelName))\n return config.CustomModelName;\n\n if (!string.IsNullOrWhiteSpace(config.SelectedModel) &&\n !string.Equals(config.SelectedModel, RimTalkConstantShim.ChooseModel, StringComparison.OrdinalIgnoreCase))\n return config.SelectedModel;\n\n return RimTalkConstantShim.DefaultCloudModel;\n }\n\n private static string ResolveEndpoint(ApiConfig config, string model)\n {\n if (config == null) return string.Empty;\n\n switch (config.Provider)\n {\n case AIProvider.Google:\n return $\"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={config.ApiKey}\";\n case AIProvider.Player2:\n return NormalizePlayer2Endpoint(GetProviderEndpointUrl(config.Provider));\n case AIProvider.Local:\n case AIProvider.Custom:\n return NormalizeEndpoint(config.BaseUrl);\n default:\n return GetProviderEndpointUrl(config.Provider);\n }\n }\n\n private static string GetProviderEndpointUrl(AIProvider provider)\n {\n var endpoint = TryGetProviderEndpointFromRegistry(provider);\n if (!string.IsNullOrWhiteSpace(endpoint))\n return endpoint;\n\n switch (provider.ToString())\n {\n case \"OpenAI\":\n return \"https://api.openai.com/v1/chat/completions\";\n case \"DeepSeek\":\n return \"https://api.deepseek.com/v1/chat/completions\";\n case \"Grok\":\n return \"https://api.x.ai/v1/chat/completions\";\n case \"GLM\":\n return \"https://api.z.ai/api/paas/v4/chat/completions\";\n case \"OpenRouter\":\n return \"https://openrouter.ai/api/v1/chat/completions\";\n case \"AlibabaIntl\":\n return \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions\";\n case \"AlibabaCN\":\n return \"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions\";\n case \"Player2\":\n return \"https://api.player2.game\";\n default:\n return string.Empty;\n }\n }\n\n private static string TryGetProviderEndpointFromRegistry(AIProvider provider)\n {\n try\n {\n var registryType = typeof(AIProvider).Assembly.GetType(\"RimTalk.AIProviderRegistry\");\n if (registryType == null) return null;\n var method = registryType.GetMethod(\"GetEndpointUrl\",\n BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(AIProvider) }, null);\n if (method == null) return null;\n return method.Invoke(null, new object[] { provider }) as string;\n }\n catch\n {\n return null;\n }\n }\n\n private static string NormalizePlayer2Endpoint(string baseUrl)\n {\n if (string.IsNullOrWhiteSpace(baseUrl)) return string.Empty;\n var trimmed = baseUrl.Trim().TrimEnd('/');\n if (trimmed.EndsWith(\"/chat/completions\", StringComparison.OrdinalIgnoreCase))\n return trimmed;\n if (trimmed.EndsWith(\"/v1\", StringComparison.OrdinalIgnoreCase))\n return trimmed + \"/chat/completions\";\n return trimmed + \"/v1/chat/completions\";\n }\n\n private static string NormalizeEndpoint(string baseUrl)\n {\n if (string.IsNullOrWhiteSpace(baseUrl)) return string.Empty;\n\n var trimmed = baseUrl.Trim().TrimEnd('/');\n if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri))\n return trimmed;\n\n if (string.IsNullOrEmpty(uri.AbsolutePath) || uri.AbsolutePath == \"/\")\n return trimmed + \"/v1/chat/completions\";\n\n return trimmed;\n }\n\n private static string BuildRequestJson(AIProvider provider, string model, string context, string prompt, int maxTokens)\n {\n if (provider == AIProvider.Google)\n {\n var combined = string.IsNullOrWhiteSpace(context)\n ? prompt ?? string.Empty\n : $\"{context}\\n{prompt}\";\n\n var request = new GeminiRequest\n {\n Contents = new[] { new GeminiContent { Parts = new[] { new GeminiPart { Text = combined } } } },\n GenerationConfig = new GeminiGenerationConfig\n {\n Temperature = 0.7f,\n MaxOutputTokens = maxTokens\n }\n };\n\n return JsonUtil.SerializeToJson(request);\n }\n\n var messages = new[]\n {\n new OpenAIMessage { Role = \"system\", Content = context ?? string.Empty },\n new OpenAIMessage { Role = \"user\", Content = prompt ?? string.Empty }\n };\n\n var openAiRequest = new OpenAIRequest\n {\n Model = model,\n Messages = messages,\n Temperature = 0.7f,\n MaxTokens = maxTokens\n };\n\n if (provider == AIProvider.Custom)\n {\n openAiRequest.MaxOutputTokens = maxTokens;\n openAiRequest.MaxCompletionTokens = maxTokens;\n }\n\n return JsonUtil.SerializeToJson(openAiRequest);\n }\n\n private static async Task PostJsonAsync(int requestId, string url, string json, string apiKey, AIProvider provider)\n {\n byte[] bodyRaw;\n try\n {\n bodyRaw = Encoding.UTF8.GetBytes(json ?? string.Empty);\n }\n catch (Exception ex)\n {\n Log.Warning($\"[RimTalk LE] [Req {requestId}] Request payload encode failed: {ex.GetType().Name} - {ex.Message}\");\n throw;\n }\n\n Log.Message($\"[RimTalk LE] [Req {requestId}] HTTP request via UnityWebRequest: provider={provider}, url={SanitizeEndpoint(provider, url)}, bodyBytes={bodyRaw.Length}\");\n\n using var webRequest = new UnityWebRequest(url, \"POST\");\n webRequest.uploadHandler = new UploadHandlerRaw(bodyRaw);\n webRequest.downloadHandler = new DownloadHandlerBuffer();\n webRequest.SetRequestHeader(\"Content-Type\", \"application/json\");\n if (provider != AIProvider.Google && !string.IsNullOrWhiteSpace(apiKey))\n webRequest.SetRequestHeader(\"Authorization\", $\"Bearer {apiKey}\");\n if (provider == AIProvider.Player2)\n webRequest.SetRequestHeader(\"X-Game-Client-Id\", Player2GameClientId);\n webRequest.timeout = Math.Max(5, TimeoutMs / 1000);\n\n var sw = Stopwatch.StartNew();\n await SendUnityWebRequestAsync(webRequest);\n\n string responseText = webRequest.downloadHandler?.text;\n if (webRequest.responseCode >= 400 || webRequest.isNetworkError || webRequest.isHttpError)\n {\n string detail = BuildSafePreview(responseText, 300);\n Log.Warning($\"[RimTalk LE] [Req {requestId}] HTTP {(int)webRequest.responseCode}: {webRequest.error ?? \"(no error)\"} body={detail}\");\n return null;\n }\n\n Log.Message($\"[RimTalk LE] [Req {requestId}] Response status: {webRequest.responseCode} in {sw.ElapsedMilliseconds} ms.\");\n return responseText;\n }\n\n private static string ExtractContent(AIProvider provider, string responseText, int requestId)\n {\n var regex = provider == AIProvider.Google ? GoogleResponseRegex : OpenAIResponseRegex;\n var matches = regex.Matches(responseText);\n if (matches.Count == 0)\n {\n Log.Warning($\"[RimTalk LE] [Req {requestId}] No content fragments matched in response.\");\n return null;\n }\n\n var sb = new StringBuilder();\n foreach (Match match in matches)\n sb.Append(match.Groups[1].Value);\n\n Log.Message($\"[RimTalk LE] [Req {requestId}] Content fragments: {matches.Count}.\");\n return Regex.Unescape(sb.ToString());\n }\n\n private static string ExtractJsonPayload(string content)\n {\n if (string.IsNullOrWhiteSpace(content)) return null;\n\n var trimmed = content.Trim();\n if (trimmed.StartsWith(\"```\", StringComparison.Ordinal))\n {\n int firstLine = trimmed.IndexOf('\\n');\n int lastFence = trimmed.LastIndexOf(\"```\", StringComparison.Ordinal);\n if (firstLine >= 0 && lastFence > firstLine)\n trimmed = trimmed.Substring(firstLine + 1, lastFence - firstLine - 1).Trim();\n }\n\n int start = trimmed.IndexOf('{');\n int end = trimmed.LastIndexOf('}');\n if (start >= 0 && end > start)\n return trimmed.Substring(start, end - start + 1).Trim();\n\n if (trimmed.StartsWith(\"{\", StringComparison.Ordinal) && trimmed.EndsWith(\"}\", StringComparison.Ordinal))\n return trimmed;\n\n return null;\n }\n\n private static string TrimPreview(string text)\n {\n if (string.IsNullOrWhiteSpace(text)) return \"(empty)\";\n const int maxLen = 160;\n var trimmed = text.Trim();\n return trimmed.Length <= maxLen ? trimmed : trimmed.Substring(0, maxLen) + \"...\";\n }\n\n private static string BuildSafePreview(string value, int maxChars)\n {\n if (string.IsNullOrEmpty(value)) return \"(empty)\";\n\n var sb = new StringBuilder();\n int limit = Math.Max(0, maxChars);\n for (int i = 0; i < value.Length && sb.Length < limit; i++)\n {\n char ch = value[i];\n\n if (char.IsControl(ch))\n {\n if (ch == '\\r' || ch == '\\n' || ch == '\\t')\n sb.Append(' ');\n else\n sb.Append($\"\\\\u{(int)ch:X4}\");\n continue;\n }\n\n if (char.IsHighSurrogate(ch))\n {\n if (i + 1 < value.Length && char.IsLowSurrogate(value[i + 1]))\n {\n sb.Append(ch);\n sb.Append(value[i + 1]);\n i++;\n }\n else\n {\n sb.Append($\"\\\\u{(int)ch:X4}\");\n }\n continue;\n }\n\n if (char.IsLowSurrogate(ch))\n {\n sb.Append($\"\\\\u{(int)ch:X4}\");\n continue;\n }\n\n sb.Append(ch);\n }\n\n if (value.Length > maxChars)\n sb.Append(\"...\");\n\n return sb.ToString();\n }\n\n private static string SanitizeApiKey(string apiKey)\n {\n if (string.IsNullOrWhiteSpace(apiKey)) return \"(empty)\";\n if (apiKey.Length <= 8) return $\"{apiKey.Substring(0, 2)}...\";\n return $\"{apiKey.Substring(0, 4)}...{apiKey.Substring(apiKey.Length - 4)}\";\n }\n\n private static string SanitizeEndpoint(AIProvider provider, string endpoint)\n {\n if (string.IsNullOrWhiteSpace(endpoint)) return \"(empty)\";\n if (provider != AIProvider.Google) return endpoint;\n int keyIndex = endpoint.IndexOf(\"key=\", StringComparison.OrdinalIgnoreCase);\n if (keyIndex < 0) return endpoint;\n return endpoint.Substring(0, keyIndex + 4) + \"***\";\n }\n\n private static async Task TryResolvePlayer2LocalKeyAsync(int requestId)\n {\n if (!string.IsNullOrWhiteSpace(_player2LocalKey))\n return _player2LocalKey;\n\n var now = DateTime.UtcNow;\n if (now - _player2LocalKeyCheckedAt < Player2LocalCheckInterval)\n return null;\n\n _player2LocalKeyCheckedAt = now;\n\n if (!await IsPlayer2LocalHealthyAsync(requestId))\n return null;\n\n var localKey = await RequestPlayer2LocalKeyAsync(requestId);\n if (!string.IsNullOrWhiteSpace(localKey))\n {\n _player2LocalKey = localKey;\n Log.Message($\"[RimTalk LE] [Req {requestId}] Player2 local app authenticated.\");\n }\n\n return _player2LocalKey;\n }\n\n private static async Task IsPlayer2LocalHealthyAsync(int requestId)\n {\n try\n {\n using var healthRequest = UnityWebRequest.Get(Player2LocalHealthUrl);\n healthRequest.timeout = Player2LocalHealthTimeoutSeconds;\n await SendUnityWebRequestAsync(healthRequest);\n if (healthRequest.isNetworkError || healthRequest.isHttpError)\n {\n Log.Message($\"[RimTalk LE] [Req {requestId}] Player2 local health check failed: {healthRequest.responseCode} - {healthRequest.error}\");\n return false;\n }\n return healthRequest.responseCode == (long)HttpStatusCode.OK;\n }\n catch (Exception ex)\n {\n Log.Message($\"[RimTalk LE] [Req {requestId}] Player2 local health check exception: {ex.Message}\");\n return false;\n }\n }\n\n private static async Task RequestPlayer2LocalKeyAsync(int requestId)\n {\n try\n {\n using var loginRequest = new UnityWebRequest(Player2LocalLoginUrl, \"POST\");\n loginRequest.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(\"{}\"));\n loginRequest.downloadHandler = new DownloadHandlerBuffer();\n loginRequest.SetRequestHeader(\"Content-Type\", \"application/json\");\n loginRequest.timeout = Player2LocalLoginTimeoutSeconds;\n\n await SendUnityWebRequestAsync(loginRequest);\n if (loginRequest.isNetworkError || loginRequest.isHttpError)\n {\n Log.Message($\"[RimTalk LE] [Req {requestId}] Player2 local login failed: {loginRequest.responseCode} - {loginRequest.error}\");\n return null;\n }\n\n var auth = JsonUtil.DeserializeFromJson(loginRequest.downloadHandler.text);\n if (!string.IsNullOrWhiteSpace(auth?.ApiKey))\n return auth.ApiKey;\n\n Log.Warning($\"[RimTalk LE] [Req {requestId}] Player2 local app responded but no API key in response.\");\n return null;\n }\n catch (Exception ex)\n {\n Log.Message($\"[RimTalk LE] [Req {requestId}] Player2 local login exception: {ex.Message}\");\n return null;\n }\n }\n\n private static Task SendUnityWebRequestAsync(UnityWebRequest request)\n {\n var tcs = new TaskCompletionSource();\n request.SendWebRequest().completed += _ => tcs.SetResult(true);\n return tcs.Task;\n }\n\n [DataContract]\n private sealed class OpenAIRequest\n {\n [DataMember(Name = \"model\")] public string Model;\n [DataMember(Name = \"messages\")] public OpenAIMessage[] Messages;\n [DataMember(Name = \"temperature\", EmitDefaultValue = false)] public float Temperature;\n [DataMember(Name = \"max_tokens\", EmitDefaultValue = false)] public int? MaxTokens;\n [DataMember(Name = \"max_output_tokens\", EmitDefaultValue = false)] public int? MaxOutputTokens;\n [DataMember(Name = \"max_completion_tokens\", EmitDefaultValue = false)] public int? MaxCompletionTokens;\n }\n\n [DataContract]\n private sealed class OpenAIMessage\n {\n [DataMember(Name = \"role\")] public string Role;\n [DataMember(Name = \"content\")] public string Content;\n }\n\n [DataContract]\n private sealed class GeminiRequest\n {\n [DataMember(Name = \"contents\")] public GeminiContent[] Contents;\n [DataMember(Name = \"generationConfig\")] public GeminiGenerationConfig GenerationConfig;\n }\n\n [DataContract]\n private sealed class GeminiContent\n {\n [DataMember(Name = \"parts\")] public GeminiPart[] Parts;\n }\n\n [DataContract]\n private sealed class GeminiPart\n {\n [DataMember(Name = \"text\")] public string Text;\n }\n\n [DataContract]\n private sealed class GeminiGenerationConfig\n {\n [DataMember(Name = \"temperature\")] public float Temperature;\n [DataMember(Name = \"maxOutputTokens\")] public int MaxOutputTokens;\n }\n\n [DataContract]\n private sealed class Player2LocalAuthResponse\n {\n [DataMember(Name = \"p2Key\")] public string ApiKey = string.Empty;\n }\n\n\n private static int ResolveMaxOutputTokens()\n {\n var settings = LiteratureMod.Settings;\n int target = settings?.synopsisTokenTarget ?? LiteratureSettingsDef.DefaultSynopsisTokenTarget;\n int maxTokens = target + LiteratureSettingsDef.StoryTokenBonus + OutputTokenOverhead;\n if (maxTokens < MinOutputTokens) maxTokens = MinOutputTokens;\n if (maxTokens > MaxOutputTokensCap) maxTokens = MaxOutputTokensCap;\n return maxTokens;\n }\n\n private static void LogResponseMeta(AIProvider provider, string responseText, int requestId)\n {\n if (string.IsNullOrWhiteSpace(responseText)) return;\n\n string finishReason = null;\n if (provider == AIProvider.Google)\n {\n var match = GoogleFinishReasonRegex.Match(responseText);\n if (match.Success) finishReason = match.Groups[1].Value;\n }\n else\n {\n var match = OpenAIFinishReasonRegex.Match(responseText);\n if (match.Success) finishReason = match.Groups[1].Value;\n }\n\n if (!string.IsNullOrWhiteSpace(finishReason))\n Log.Message($\"[RimTalk LE] [Req {requestId}] finish_reason: {finishReason}\");\n\n if (provider == AIProvider.Google)\n {\n if (TryParseUsage(GoogleUsageRegex, responseText, out var prompt, out var completion, out var total))\n Log.Message($\"[RimTalk LE] [Req {requestId}] token usage: prompt={prompt}, completion={completion}, total={total}\");\n }\n else\n {\n if (TryParseUsage(OpenAIUsageRegex, responseText, out var prompt, out var completion, out var total))\n Log.Message($\"[RimTalk LE] [Req {requestId}] token usage: prompt={prompt}, completion={completion}, total={total}\");\n }\n }\n\n private static bool TryParseUsage(Regex regex, string text, out int prompt, out int completion, out int total)\n {\n prompt = 0;\n completion = 0;\n total = 0;\n\n if (regex == null || string.IsNullOrWhiteSpace(text)) return false;\n\n var match = regex.Match(text);\n if (!match.Success || match.Groups.Count < 4) return false;\n\n return int.TryParse(match.Groups[1].Value, out prompt) &&\n int.TryParse(match.Groups[2].Value, out completion) &&\n int.TryParse(match.Groups[3].Value, out total);\n }\n }\n}\n"}}}}