Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 6882fabc95 Simplify eagle-exec by using active instance file
Instead of querying nginx config on every invocation, deploy-blue-green.sh
now writes the active instance to /opt/eagle0/.active-instance.

eagle-exec reads this file directly, with a fallback to checking running
containers if the file doesn't exist.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:33:02 -08:00
84c1bf61b0 Add eagle-exec helper for blue-green deployments (#5567)
Adds a helper script that automatically runs docker exec against the
active Eagle instance (blue or green), determined by checking nginx
config.

Usage:
  eagle-exec printenv GEMINI_API_KEY
  eagle-exec jcmd 1 VM.flags
  eagle-exec sh

The script is deployed to /opt/eagle0/scripts/ and symlinked to
/usr/local/bin/eagle-exec for easy access.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:31:53 -08:00
21dfa12197 Fix Gemini 404 errors by adding missing model name case (#5566)
When LlmProvider was set to "gemini", the validation code was missing a
case for extracting the GeminiModelName, causing it to default to an
empty string. This resulted in malformed API URLs like:
https://generativelanguage.googleapis.com/v1beta/models/:streamGenerateContent

which returned 404 errors.

Added the "gemini" case to properly extract GeminiModelName from
settings.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:12:33 -08:00
de4c568aa0 Handle LLM validation errors gracefully (#5565)
When switching LLM providers in the admin console, if the API key for
the target provider isn't configured, the validation would crash with
ExceptionInInitializerError. This fix:

- Wraps service creation in Try to catch initialization errors
- Logs the error with context (likely missing API key)
- Reports to Sentry for monitoring
- Returns a user-friendly error message
- Falls back to keeping the old setting unchanged

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:53:05 -08:00
edccde9a90 Fix Gemini provider validation in admin settings (#5564)
The LLM settings validation in GameAdminServiceImpl was missing the
"gemini" provider case, causing "Unknown provider: gemini" errors when
trying to switch to Gemini from the admin console.

Changes:
- Add GeminiServiceImpl import
- Add "gemini" case to validateLlmSettings()
- Add "GeminiModelName" to llmSettingKeys set
- Add gemini_service_impl dep to BUILD.bazel

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:09:50 -08:00
0f7f019f14 Replace characters that could cause IP/publicity issues (#5563)
Replace 13 heroes that referenced real people or copyrighted characters
with original alternatives that preserve gameplay stats and general archetypes:

Real people replaced:
- Jesse "The Body" Ventura → Magnus "The Boulder" Varnok
- Jeb! → Aldric the Overlooked
- Ringo → Tomlin the Tuneful
- Flea → Zander the Restless

Copyrighted characters replaced:
- Darth Plagueis → Voros the Undying
- Deckard Cain → Old Marek the Learned
- Skeletor → Karvax the Fleshless
- Trap Jaw → Ironmaw
- Shu Lien → Mei Shen
- Roger the Shrubber → Hedrick the Hedge-Merchant
- Kriss Kross → The Tumbler
- Daddy Mac → Silvio the Smooth
- Ul (removed Warcraft "Far Seer" reference)

Image files in eagle0-headshots bucket have been renamed to match.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:45:11 -08:00
adcafe3224 Refactor LLM update interface to use Scala domain types instead of protos (#5562)
* Refactor LLM update interface to use Scala domain types instead of protos

Replace proto types (GeneratedTextRequest, LLMResponse) with Scala domain
type (GeneratedTextRequestT) throughout the LLM streaming interface. This
eliminates unnecessary proto conversions and simplifies the code.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove unused llm_response_scala_proto dependency from llm_resolver

The LLMResponse proto is no longer used since we now pass Scala domain
types through the LLM update interface.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:38:34 -08:00
89ad8371b5 Add Gemini LLM support with model comparison documentation (#5561)
This adds Google Gemini as a third LLM provider option alongside OpenAI
and Anthropic (Claude). Based on streaming latency tests, Gemini 2.5
Flash-Lite shows the fastest time-to-first-token (~0.6s) at the lowest
cost ($0.10/$0.40 per 1M tokens).

Changes:
- Add GeminiServiceImpl.scala following OpenAI/Claude pattern
- Add GeminiModelName setting (default: gemini-2.5-flash-lite)
- Update LlmResolver to support "gemini" provider
- Update admin console dropdowns with Gemini options
- Reorder dropdown options to show recommended models first:
  - OpenAI: gpt-4.1-mini (was gpt-5-mini)
  - Claude: claude-3-5-haiku (already first)
  - Gemini: gemini-2.5-flash-lite
- Add GEMINI_API_KEY to docker-compose and deployment workflow
- Add docs/LLM_MODEL_COMPARISON.md with latency/pricing comparison

Streaming TTFT results from testing:
- Gemini 2.5 Flash-Lite: ~0.6s (fastest, cheapest)
- gpt-4.1-mini: ~1.7s
- claude-3-5-haiku: ~1.9s
- Gemini 2.5 Flash: ~6.8s (surprisingly slow)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:23:38 -08:00
b8c584572d Filter LLM requests for deleted games before sending (#5560)
* Filter LLM requests for deleted games before sending

Add a game validity check to LlmResolver to prevent sending LLM requests
for games that have been deleted (e.g., warmup games during blue-green
deployments). This avoids wasted API calls and eliminates the "Ignoring
LLM response for deleted game" log messages at startup.

Changes:
- Add LlmResolverGameDeleted result type to LlmResolver
- Add isGameValid callback parameter to LlmResolver constructor
- Check game validity before sending each request
- Handle LlmResolverGameDeleted in UnrequestedTextHandler
- Pass gameControllerInfos.contains check from GamesManager

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Move proto conversion inside TextGenerationSuccess case

The proto conversion is only needed for the success case where we
actually send the request. Moving it avoids unnecessary conversion
for dependency-blocked requests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:10:11 -08:00
acb411c4d2 Reorder Claude models to put haiku first (#5559)
Move claude-3-5-haiku to the top of the dropdown since it's the
fastest model for streaming responses (~1.9s TTFT vs ~5.0s for sonnet).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:00:31 -08:00
25f92c7832 Add ANTHROPIC_API_KEY to deployment pipeline (#5558)
* Add ANTHROPIC_API_KEY to docker-compose environment

Required for Claude LLM provider to work in production.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add ANTHROPIC_API_KEY to deployment workflow

Pass the secret to the deploy job and export it for docker-compose.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 07:25:04 -08:00
0f0d2433bb Fix reasoning_effort for non-reasoning models, add gpt-5.1 option (#5557)
* Only send reasoning_effort for reasoning models (o1, o3)

The reasoning_effort parameter is only valid for OpenAI reasoning models.
Sending it to non-reasoning models like gpt-5-mini causes a 400 Bad Request.

Now only includes reasoning_effort when the model name starts with "o1" or "o3".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Treat models without -mini/-nano as reasoning models, add gpt-5.1 option

- Change reasoning model detection: models with -mini or -nano suffix are
  non-reasoning; all others (gpt-5.1, gpt-5.2, o1, o3, etc.) get reasoning_effort
- Add gpt-5.1 to admin console dropdown options

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:00:53 -08:00
eea61f1c6a Fix create game defaults: 7 total players, 1 human (#5556)
The previous change incorrectly set both total players and human players
to 1. This restores the intended behavior: 7 total players (1 human + 6 AI)
with 1 human player selected by default.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:59:29 -08:00
25 changed files with 585 additions and 188 deletions
+5 -1
View File
@@ -162,6 +162,7 @@ jobs:
JFR_SIDECAR_IMAGE: ${{ needs.build-all.outputs.jfr_sidecar_image_tag }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
@@ -216,7 +217,7 @@ jobs:
# Copy files
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh scripts/eagle-exec.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
- name: Deploy to production droplet
@@ -281,6 +282,7 @@ jobs:
export JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
export OPENAI_API_KEY="${OPENAI_API_KEY}"
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}"
export GEMINI_API_KEY="${GEMINI_API_KEY}"
export GPT_MODEL_NAME="${GPT_MODEL_NAME:-gpt-4o}"
export EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3:-false}"
export DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
@@ -372,6 +374,8 @@ jobs:
# Note: Auth is deployed separately via auth_build.yml - do NOT touch auth here
chmod +x /opt/eagle0/scripts/*.sh
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
# Create eagle-exec alias (symlink in /usr/local/bin)
sudo ln -sf /opt/eagle0/scripts/eagle-exec.sh /usr/local/bin/eagle-exec
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
+2
View File
@@ -27,6 +27,7 @@ services:
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
@@ -75,6 +76,7 @@ services:
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
+94
View File
@@ -0,0 +1,94 @@
# LLM Model Comparison
This document compares streaming latency (time-to-first-token) and pricing across OpenAI, Anthropic (Claude), and Google (Gemini) models for use in Eagle's narrative text generation.
## Test Methodology
All tests were performed locally using curl with streaming enabled. Each model was tested 3 times with the same prompt:
> "Write a short paragraph about a brave knight who discovers a hidden cave. Make it vivid and descriptive."
Time-to-first-token (TTFT) was measured from request initiation to the first text content appearing in the stream.
## Streaming Latency Results (January 2026)
| Model | Run 1 | Run 2 | Run 3 | Average TTFT |
|-------|-------|-------|-------|--------------|
| **Gemini 2.5 Flash-Lite** | 0.76s | 0.54s | 0.53s | **~0.6s** |
| **gpt-4.1-mini** | 1.65s | 1.72s | 1.68s | **~1.7s** |
| **claude-3-5-haiku** | 1.85s | 1.92s | 1.88s | **~1.9s** |
| gpt-5.2 | 3.25s | 3.38s | 3.32s | **~3.3s** |
| gpt-5-mini | 2.52s | 5.82s | 3.12s | **~3.8s** (high variance) |
| Gemini 3 Flash Preview | 4.11s | 4.77s | 4.28s | **~4.4s** |
| claude-sonnet-4 | 4.89s | 5.12s | 4.98s | **~5.0s** |
| Gemini 2.5 Flash | 5.60s | 7.00s | 7.79s | **~6.8s** |
## Pricing Comparison (per 1M tokens)
| Model | Input Price | Output Price | Notes |
|-------|-------------|--------------|-------|
| **Gemini 2.5 Flash-Lite** | $0.10 | $0.40 | Cheapest and fastest |
| Gemini 2.5 Flash | $0.15 | $0.60 | |
| gpt-5-mini | $0.25 | $2.00 | |
| **gpt-4.1-mini** | $0.40 | $1.60 | Best OpenAI value |
| Gemini 3 Flash Preview | $0.50 | $3.00 | Includes thinking tokens |
| **claude-3-5-haiku** | $0.80 | $4.00 | Best Anthropic value |
| gpt-5.2 | ~$1.00 | ~$10.00 | Full reasoning model |
| Gemini 2.5 Pro | $1.25 | $10.00 | |
| Gemini 3 Pro Preview | $2.00 | $12.00 | ≤200K context |
| claude-sonnet-4 | $3.00 | $15.00 | |
## Recommendations
### For Narrative Text Generation (Default)
**Gemini 2.5 Flash-Lite** is recommended as the default:
- Fastest TTFT (~0.6s) - nearly 3x faster than alternatives
- Cheapest pricing ($0.10/$0.40 per 1M tokens)
- Quality is acceptable for short narrative snippets
### Alternative Options
| Priority | Model | When to Use |
|----------|-------|-------------|
| Speed + Cost | Gemini 2.5 Flash-Lite | Default for most use cases |
| Speed + Quality | gpt-4.1-mini | When you need OpenAI quality with good speed |
| Instruction Following | claude-3-5-haiku | Complex multi-step prompts, consistent tone |
| Maximum Quality | claude-sonnet-4 or gpt-5.2 | When output quality is paramount |
### Quality Trade-offs
For short narrative snippets (1-3 paragraphs):
- **Flash-Lite vs Haiku/4.1-mini**: Minor quality difference, significant speed gain
- **Haiku vs Sonnet**: Noticeable quality difference in creative writing variety
- **gpt-4.1-mini vs gpt-5.2**: Moderate quality difference, significant cost savings
## Configuration
LLM settings can be changed at runtime via the admin console:
1. Navigate to Admin Console → Settings
2. Change `LlmProvider` to select vendor (gemini, openai, claude)
3. Change the corresponding model name setting:
- `GeminiModelName` (default: gemini-2.5-flash-lite)
- `OpenAiModelName` (default: gpt-4.1-mini)
- `ClaudeModelName` (default: claude-3-5-haiku-20241022)
Changes take effect on the next LLM request.
## Environment Variables
For production deployment, ensure API keys are set:
```bash
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GEMINI_API_KEY=AIza...
```
## Notes
- **gpt-5-mini** showed high latency variance (2.5s - 5.8s) in testing
- **Gemini 2.5 Flash** was surprisingly slower than Flash-Lite, possibly due to internal reasoning overhead
- **Gemini 3 Flash** is a frontier model with better quality but higher latency than 2.5 Flash-Lite
- All Gemini models have a generous free tier (up to 1,000 daily requests)
+5
View File
@@ -35,6 +35,7 @@ WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
SAVES_DIR="${APP_DIR}/saves"
FLUSH_MARKER="${SAVES_DIR}/.flush_complete"
DEPLOYMENT_IN_PROGRESS="${SAVES_DIR}/.deployment_in_progress"
ACTIVE_INSTANCE_FILE="${APP_DIR}/.active-instance"
# Colors for output
RED='\033[0;31m'
@@ -298,6 +299,10 @@ main() {
create_flush_marker "${deploy_id}" "eagle-${staging}"
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
# Write active instance file for eagle-exec helper
echo "eagle-${staging}" > "${ACTIVE_INSTANCE_FILE}"
log_info "[DEPLOY:${deploy_id}] Active instance file updated: eagle-${staging}"
# Update .env for admin service
local env_file="${APP_DIR}/.env"
if [ "$staging" = "green" ]; then
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
#
# Helper to run docker exec against the active Eagle instance.
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
#
# Usage:
# eagle-exec printenv GEMINI_API_KEY
# eagle-exec jcmd 1 VM.flags
# eagle-exec sh # Get a shell
#
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/eagle0}"
ACTIVE_FILE="${APP_DIR}/.active-instance"
# Read active instance from file, with fallback
if [ -f "$ACTIVE_FILE" ]; then
ACTIVE=$(cat "$ACTIVE_FILE")
else
# Fallback: check which container is actually running
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
ACTIVE="eagle-blue"
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
ACTIVE="eagle-green"
else
ACTIVE=""
fi
fi
if [ -z "$ACTIVE" ]; then
echo "Error: No active Eagle instance found" >&2
exit 1
fi
if [ $# -eq 0 ]; then
echo "Active instance: $ACTIVE"
echo "Usage: $0 <command> [args...]"
echo "Example: $0 printenv GEMINI_API_KEY"
exit 0
fi
exec docker exec "$ACTIVE" "$@"
@@ -79,8 +79,8 @@ public class CreateGameItem : MonoBehaviour {
totalPlayersDropdown.ClearOptions();
totalPlayersDropdown.AddOptions(
Enumerable.Range(1, maxPlayerCount).Select(i => i.ToString()).ToList());
// Default to 1-player game (index 0 = 1 player)
totalPlayersDropdown.value = 0;
// Default to 7 total players (index 6 = 7 players, so 1 human + 6 AI)
totalPlayersDropdown.value = Math.Min(6, maxPlayerCount - 1);
SetHumanPlayersDropdown();
// Default to 1 human (index 0)
@@ -1167,10 +1167,11 @@ type SettingInfo struct {
DropdownOptions []string `json:"dropdown_options,omitempty"`
}
// LLM model dropdown options
var llmProviderOptions = []string{"openai", "claude"}
var openAiModelOptions = []string{"gpt-5-mini", "gpt-5.1", "gpt-5.2", "gpt-4.1", "gpt-4.1-mini", "gpt-4o", "gpt-4o-mini", "o1", "o1-mini", "o3-mini"}
var claudeModelOptions = []string{"claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-3-7-sonnet-20250219", "claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022"}
// LLM model dropdown options (recommended models first)
var llmProviderOptions = []string{"gemini", "openai", "claude"}
var openAiModelOptions = []string{"gpt-4.1-mini", "gpt-5-mini", "gpt-5.1", "gpt-5.2", "gpt-4.1", "gpt-4o", "gpt-4o-mini", "o1", "o1-mini", "o3-mini"}
var claudeModelOptions = []string{"claude-3-5-haiku-20241022", "claude-sonnet-4-20250514", "claude-opus-4-20250514", "claude-3-7-sonnet-20250219", "claude-3-5-sonnet-20241022"}
var geminiModelOptions = []string{"gemini-2.5-flash-lite", "gemini-2.5-flash", "gemini-3-flash-preview", "gemini-2.5-pro", "gemini-3-pro-preview"}
type SettingsPageData struct {
Title string
@@ -1200,6 +1201,8 @@ func getDropdownOptionsForSetting(name string) []string {
return openAiModelOptions
case "ClaudeModelName":
return claudeModelOptions
case "GeminiModelName":
return geminiModelOptions
}
return nil
}
@@ -2558,6 +2558,7 @@ ranger/male/000007.996945201.png
### fixed
fixed/aethelred_the_unready.png
fixed/alakanda.png
fixed/aldric_the_overlooked.png
fixed/bergil.png
fixed/bob_brokenbow.png
fixed/bregos_fyar.png
@@ -2565,39 +2566,38 @@ fixed/brewster.png
fixed/bridget.png
fixed/bromus.png
fixed/cardinal_richeliu.png
fixed/darth_plagueis.png
fixed/david_duckplucker.png
fixed/deckard_cain.png
fixed/eagle.png
fixed/eagle_with_hat.png
fixed/elena_fyar.png
fixed/elzix.png
fixed/flea.png
fixed/generic.png
fixed/geoffrey_the_bozo.png
fixed/hedrick_the_hedge_merchant.png
fixed/ikhaan_tarn.png
fixed/ironmaw.png
fixed/jake.png
fixed/jeb.png
fixed/jesse_the_body_ventura.png
fixed/john_ranil.png
fixed/kriss_kross.png
fixed/mack_daddy.png
fixed/karvax_the_fleshless.png
fixed/mad_mal.png
fixed/magnus_the_boulder_varnok.png
fixed/marty_mcpolitics.png
fixed/mei_shen.png
fixed/mesrop_mashtots.png
fixed/norfolk.png
fixed/old_marek_the_learned.png
fixed/rabbit_spearer.png
fixed/randall_galor.png
fixed/ringo.png
fixed/rod_rustyhelm.png
fixed/roger.png
fixed/sadar_rakon.png
fixed/sam_swordthatsbent.png
fixed/shu_lien.png
fixed/skeletor.png
fixed/trap_jaw.png
fixed/silvio_the_smooth.png
fixed/the_tumbler.png
fixed/tomlin_the_tuneful.png
fixed/turnil.png
fixed/ul.png
fixed/voros_the_undying.png
fixed/zander_the_restless.png
### mage
mage/female/000002.3495282493.png
mage/female/000003.1000321804.png
1 ### paladin
2558 ### fixed
2559 fixed/aethelred_the_unready.png
2560 fixed/alakanda.png
2561 fixed/aldric_the_overlooked.png
2562 fixed/bergil.png
2563 fixed/bob_brokenbow.png
2564 fixed/bregos_fyar.png
2566 fixed/bridget.png
2567 fixed/bromus.png
2568 fixed/cardinal_richeliu.png
fixed/darth_plagueis.png
2569 fixed/david_duckplucker.png
fixed/deckard_cain.png
2570 fixed/eagle.png
2571 fixed/eagle_with_hat.png
2572 fixed/elena_fyar.png
2573 fixed/elzix.png
fixed/flea.png
2574 fixed/generic.png
2575 fixed/geoffrey_the_bozo.png
2576 fixed/hedrick_the_hedge_merchant.png
2577 fixed/ikhaan_tarn.png
2578 fixed/ironmaw.png
2579 fixed/jake.png
fixed/jeb.png
fixed/jesse_the_body_ventura.png
2580 fixed/john_ranil.png
2581 fixed/kriss_kross.png fixed/karvax_the_fleshless.png
fixed/mack_daddy.png
2582 fixed/mad_mal.png
2583 fixed/magnus_the_boulder_varnok.png
2584 fixed/marty_mcpolitics.png
2585 fixed/mei_shen.png
2586 fixed/mesrop_mashtots.png
2587 fixed/norfolk.png
2588 fixed/old_marek_the_learned.png
2589 fixed/rabbit_spearer.png
2590 fixed/randall_galor.png
fixed/ringo.png
2591 fixed/rod_rustyhelm.png
fixed/roger.png
2592 fixed/sadar_rakon.png
2593 fixed/sam_swordthatsbent.png
2594 fixed/shu_lien.png fixed/silvio_the_smooth.png
2595 fixed/skeletor.png fixed/the_tumbler.png
2596 fixed/trap_jaw.png fixed/tomlin_the_tuneful.png
2597 fixed/turnil.png
2598 fixed/ul.png
2599 fixed/voros_the_undying.png
2600 fixed/zander_the_restless.png
2601 ### mage
2602 mage/female/000002.3495282493.png
2603 mage/female/000003.1000321804.png
+13 -13
View File
@@ -13,29 +13,29 @@ Bromus 25 65 60 45 97 292 39 98 81 66 60 mage FALSE fixed/bromus.png male Bromu
Turnil 75 74 80 80 90 399 95 70 72 92 80 ranger FALSE fixed/turnil.png male Turnil was once a cleric, exploring the lands to the east, healing the sick and fighting evil. Eventually he became convinced that he could do more good fighting evil indirectly, becoming a ranger and using his stealth abilities to get others to avoid fighting. Now he serves wherever he thinks he can do the most good for the people. stern, battle-hardened, a survivor
Bob Brokenbow 38 79 77 23 28 245 55 20 38 71 77 ranger FALSE fixed/bob_brokenbow.png male Bob Brokenbow is a competent scout, uninspiring but loyal. He got his nickname from his habit of somehow breaking all his bows, no matter how strong the wood. This is especially annoying to his allies, since the bow is the only weapon he's any good at using. reliable, uninspiring, loyal
Rod Rustyhelm 40 80 75 25 5 225 80 75 88 91 75 engineer FALSE fixed/rod_rustyhelm.png male Rod Rustyhelm is one of the most dim-witted heroes in all the land. He does have occasional moments of "inspiration," where he comes up with some ridiculously hare-brained idea and decides it's genius, and won't shut up about it. Still, he does have some skill with weapons and can be trusted to repair a bridge or even build a ballista, when needed. an idiot, eager, mostly harmless
Kriss Kross 95 75 95 80 60 405 91 98 85 63 95 noProfession FALSE fixed/kriss_kross.png other Kriss Kross is a relic from another age; most people have either forgotten about them, or wish they had. Don't try to compare them to another bad little fad. They'll give you something that you never had. Kriss Kross'll make you Jump Jump. likely to make you jump jump, musical, dated
The Tumbler 95 75 95 80 60 405 91 98 85 63 95 noProfession FALSE fixed/the_tumbler.png other The Tumbler is an acrobat from a bygone era of entertainment. Most people have either forgotten about them or wish they had. They insist their act is timeless, though audiences tend to disagree. Still, their agility is undeniable, and they can be surprisingly useful in a fight. acrobatic, outdated, persistent
Elena Fyar 75 90 85 85 90 425 82 86 71 91 85 paladin TRUE fixed/elena_fyar.png female Champions of Justice Elena Fyar is the daughter of the King and was once betrothed to his right-hand man, Shardok Ikhaan Tarn, but eventually refused him. Many at court blamed her for Tarn's betrayal, and she was forced to leave the court. She now commands her own faction. In truth, she was merely the first to see Tarn's unbridled ego and ambition, and none heeded her warnings. She is proud, but also hopes for eventual reconciliation with her father. proud, noble, formerly betrothed to Ikhaan Tarn, daughter of King Bregos Fyar
Geoffrey the Bozo 95 80 75 85 27 362 2 3 49 1 75 noProfession FALSE fixed/geoffrey_the_bozo.png male Geoffrey the Bozo is a mysterious stranger. No one takes him seriously, since both his actions and his manner of speech are ridiculous. He's untrustworthy, unambitious, and a coward. He's therefore always underestimated. But don't be fooled; he's surprisingly strong, pretty good in a fight, and overall a very useful companion to have around. He's a nightmare in your dreams! surprisingly strong, useful, mysterious
Æthelred the Unready 65 40 85 25 30 245 78 61 27 15 85 engineer FALSE fixed/aethelred_the_unready.png male Æthelred the Unready was once left in charge of a province, a charge for which he was utterly unsuited, and he did a terrible job. That earned him his nickname. Still, he's proven himself capable in the war, helping to repair battle damage in impoverished towns and even helping out in a battle now and then. ever-present, useful in a pinch, unwanted
Randall Galor 96 85 100 71 63 415 98 90 47 95 100 engineer TRUE fixed/randall_galor.png male The Bulwark Brotherhood Randall Galor is a remarkable man. He's both a warrior skilled in personal combat, and a capable engineer. He served as an engineer in the King's army for several years, but went rogue when the civil war broke out. He believes that by fortifying his territory and by building mighty catapults and trebuchets to batter down the defenses of others, he's sure to emerge victorious. a warrior, a mechanic, remarkable
Jesse "The Body" Ventura 100 85 98 91 72 446 23 97 86 91 98 champion TRUE fixed/jesse_the_body_ventura.png male We Ain't Got Time to Bleed Jesse "The Body" Ventura has tried a few routes to power. His comical, yet effective, wrestling techniques make him nearly unstoppable in single combat, but because he could never defer to another's authority, he never served either the King nor the Eagle. Now he's seeking a political path to power, but he's still not afraid to rumble when needed. In addition to his brute strength, his bald head and ridiculous mustache make him greatly feared. a wrestler, a politician, mustachioed
Ringo 37 71 60 71 80 319 91 45 79 71 60 noProfession FALSE fixed/ringo.png male Ringo gave up a promising career as a beautician to become a drummer. He travels the land, telling jokes and playing the drums for the entertainment of children everywhere. He's not sure how he ended up involved in this war. a drummer, a beautician, affable
Darth Plagueis 48 72 94 79 97 390 17 99 56 91 90 necromancer TRUE fixed/darth_plagueis.png male The Sith Lords Darth Plagueis tried to become so powerful that he could prevent the people he cared about from dying. That didn't work, so instead he became a necromancer. He is an equal opportunity practitioner of the dark arts. He'll reanimate his brother, his enemy, or the ex-girlfriends of his followers with equal passion. evil, power-hungry, terrifying
Deckard Cain 17 22 60 60 93 252 95 12 65 95 60 noProfession FALSE fixed/deckard_cain.png male Deckard Cain is highly learned, and won't hesitate to let you know. Try as you might to get away from his constant blabbering, he'll keep insisting that you stay a while and listen. In fact, he says that constantly: "Stay a while, and listen." likely to ask you to stay a while and listen, learned, talkative
Trap Jaw 99 91 98 33 37 358 17 87 41 63 98 engineer FALSE fixed/trap_jaw.png male Trap Jaw was an engineer in Skeletor's employ, before Skeletor got angry and destroyed his jaw and one of his arms. Trap Jaw was able to replace the missing parts with machinery. He doesn't seem at all intelligent, yet he does come up with clever contraptions for war. No one knows why he is blue. foolish, disloyal, metal-eating
Jeb! 56 43 61 8 81 249 62 89 12 14 61 noProfession FALSE fixed/jeb.png male Jeb is a low-energy politician, and basically a loser. He had some surprising successes earlier in his career, and some thought he was destined for great power, like his father and brother before him. When he made a move for power, though, it turned out that no one wanted him, and it turns out his father and brother were not that great, either. Now he is adrift, willing to serve the right leader but noticed by almost no one. unwanted, low-energy, a loser
Roger the Shrubber 51 95 80 85 91 402 92 15 73 71 80 ranger TRUE fixed/roger.png male Traveling Shrubbers Anonymous Roger the Shrubber is a traveling salesman, who carries shrubberies and the tools for planting and maintaining shrubberies in this cart. He is under considerable economic stress at this period in history. He disapproves of saying "ni" to old ladies. cart-riding, a traveling salesmen, under considerable economic stress at this period in history
Magnus "The Boulder" Varnok 100 85 98 91 72 446 23 97 86 91 98 champion TRUE fixed/magnus_the_boulder_varnok.png male The Iron Fist Confederacy Magnus "The Boulder" Varnok rose to fame in the fighting pits, where his theatrical style and booming voice made him a crowd favorite. Unsatisfied with mere athletic glory, he turned to politics, believing that if he could command an arena, he could command a province. He defers to no one, and his bald head and magnificent mustache are known throughout the land. a pit fighter, a politician, mustachioed
Tomlin the Tuneful 37 71 60 71 80 319 91 45 79 71 60 noProfession FALSE fixed/tomlin_the_tuneful.png male Tomlin abandoned a promising apprenticeship as a cobbler to pursue his true passion: percussion. He wanders the land with his drums, entertaining children and lifting spirits wherever he goes. He's not sure how he got caught up in this war, but he's determined to make the best of it. a drummer, formerly a cobbler, good-natured
Voros the Undying 48 72 94 79 97 390 17 99 56 91 90 necromancer TRUE fixed/voros_the_undying.png male The Ashen Circle Voros was once a healer who became obsessed with conquering death itself. When his beloved died despite his efforts, he turned to forbidden arts, reasoning that if he could not prevent death, he would simply reverse it. Now he leads a cult of necromancers who believe that the boundary between life and death is merely an inconvenience. obsessive, grief-stricken, terrifying
Old Marek the Learned 17 22 60 60 93 252 95 12 65 95 60 noProfession FALSE fixed/old_marek_the_learned.png male Old Marek has forgotten more than most scholars will ever know, and he's eager to share what he remembers with anyone who will listen. Unfortunately, he has difficulty telling when his audience has lost interest, and his lectures can stretch on for hours. He has a habit of saying "Now hold on, you'll want to hear this" just as you're trying to escape. Still, his knowledge has proven invaluable to those patient enough to hear him out. long-winded, knowledgeable, oblivious
Ironmaw 99 91 98 33 37 358 17 87 41 63 98 engineer FALSE fixed/ironmaw.png male Ironmaw was an engineer who lost his jaw and arm in a siege gone wrong. Rather than accept his fate, he built mechanical replacements from scavenged metal. He's not particularly bright, but he has an intuitive genius for war machines. No one knows why his skin has a grayish-blue pallor, and he refuses to discuss it. resourceful, dim-witted, mechanically gifted
Aldric the Overlooked 56 43 61 8 81 249 62 89 12 14 61 noProfession FALSE fixed/aldric_the_overlooked.png male Aldric comes from a prestigious family of rulers, and everyone assumed he would follow in their footsteps. When he finally made his bid for power, however, no one rallied to his cause. It turned out that his family's reputation had soured, and Aldric himself inspired no one. Now he drifts from faction to faction, hoping someone will give him a chance, but he is noticed by almost no one. uninspiring, from a famous family, a disappointment
Hedrick the Hedge-Merchant 51 95 80 85 91 402 92 15 73 71 80 ranger TRUE fixed/hedrick_the_hedge_merchant.png male The Verdant Fellowship Hedrick travels the land selling decorative shrubberies from his cart, along with the tools needed to plant and maintain them. Business has been difficult during the war, and he's grown increasingly desperate. He has strong opinions about proper manners and detests rudeness toward the elderly. a traveling salesman, polite, economically stressed
Rabbit Spearer 18 23 60 3 4 108 73 6 27 81 60 noProfession FALSE fixed/rabbit_spearer.png male Rabbit Spearer is the absolute worst. Everyone hates him, but sometimes he can talk his way into someone's retinue just as a warm body. He's quite stupid, and his strength and agility are not much better. the worst, hated, an idiot
Norfolk 81 71 87 93 73 405 30 98 92 70 87 paladin TRUE fixed/norfolk.png male Duchy of Norfolk The Duke of Norfolk is a puzzling presence in this environment, since there is no place called Norfolk here. Nonetheless, he managed to rise to power, leading a faction of discontents. a duke, unexplained, unremarkable
Elzix 73 85 91 86 68 403 41 58 99 96 91 engineer FALSE fixed/elzix.png male Elzix has been overlooked his whole life, but he's okay with that. He tends to settle down in one place and just quietly do the work, often making outrageous and amazing artifacts that he'll let adventurers gamble for. He's a lot of fun at parties. affable, honest, clever
Ul 71 52 92 81 95 391 87 70 65 88 92 paladin FALSE fixed/ul.png male Ul is an affable shaman who one day dreams of becoming a Far Seer. No one knows where he came from, or how an orc like him got to the Kingdom, or what an orc is, anyway, but he doesn't let that bother him. He's just here to kick butt and heal wounds. affable, a shaman, an orc
Ul 71 52 92 81 95 391 87 70 65 88 92 paladin FALSE fixed/ul.png male Ul is an affable shaman who dreams of one day mastering the deeper mysteries of his craft. No one knows where he came from, or how a green-skinned tusked warrior like him arrived in the Kingdom, but he doesn't let that bother him. He's just here to fight evil and heal the wounded. affable, a shaman, an outsider
Brewster 82 32 87 93 56 350 79 12 88 71 87 noProfession FALSE fixed/brewster.png male Brewster is a man of no special talents, but generally a decent guy to have around. He travels the land, but nobody knows why. Nobody had even heard of him before the war began. mysterious, friendly, talkative
Mesrop Mashtots 36 90 60 75 98 359 95 81 50 81 60 ranger FALSE fixed/mesrop_mashtots.png male Mesrop Mashtots is a great scholar, who has invented many alphabets, because why stop with just one? He's traveled to far-away lands and lived to tell the tale. He came back to find the Kingdom in civil war, and has not yet chosen his side. scholarly, an inventor of alphabets, well-traveled
David Duckplucker 21 20 60 14 7 122 80 12 60 81 60 ranger FALSE fixed/david_duckplucker.png male David Duckplucker is an idiot, but he throws himself into every project with great gusto. His friends started calling him Duckplucker as a challenge, but try as he might, he has never successfully plucked a duck. You could do worse than to hire David Duckplucker, but you couldn't do much worse. an idiot, eager, incapable of plucking a duck
Pea-Brained Jake-o the Peacock Floofio Majestico 91 28 78 100 23 320 32 51 100 14 78 ranger FALSE fixed/jake.png male Pea-Brained Jake-o the Peacock Floofio Majestico is one of the most beloved heroes in all the land. He would never hurt anyone on purpose, and is quite clumsy, yet somehow a skilled hunter. Most of all, he's extraordinarily handsome, with a lush red mane that is the envy of all the world. handsome, lovable, a doofus
Flea 85 95 90 96 82 448 45 91 93 74 90 champion TRUE fixed/flea.png male The Syncopated Sanctum Flea is a crazy bassist. Soft spoken with a broken jaw, step outside but not to brawl. Fully loaded we got snacks and supplies. I don't ever wanna feel like I did that day, take me to the place I love, take me all the way. a bassist, musical, crazy
Daddy Mac 92 80 90 83 71 416 61 43 81 95 90 noProfession FALSE fixed/mack_daddy.png male Daddy Mac is a traveling musician. He's well liked everywhere he goes, especially by the ladies. No one knows exactly what his motivations are, but one thing's for sure: The Daddy Mac'll make you Jump Jump. He'll make you bump bump, wiggle and shake your rump. useful, a ladies' man, likely to make you jump jump
Zander the Restless 85 95 90 96 82 448 45 91 93 74 90 champion TRUE fixed/zander_the_restless.png male The Syncopated Sanctum Zander is a wild-eyed musician who plays the lute with a ferocity no one has seen before. He's soft-spoken until he performs, at which point he becomes a whirlwind of energy. He founded a commune of like-minded artists who believe that music can change the world, or at least make it more bearable. a musician, intense, unpredictable
Silvio the Smooth 92 80 90 83 71 416 61 43 81 95 90 noProfession FALSE fixed/silvio_the_smooth.png male Silvio is a traveling entertainer known for his charm and his way with words. He's popular wherever he goes, especially among the ladies. No one is quite sure what his goals are, but he's pleasant company and surprisingly handy in a scrap. charming, a ladies' man, easygoing
Bridget 83 91 92 98 100 464 62 97 92 100 92 mage TRUE fixed/bridget.png female Vengeance Bridget's teachers and fellow pupils at the Academy constantly underestimated her, and she grew disillusioned. When the Eagle arrived, she jumped at the chance to join his rebellion, and she learned much from him, becoming one of the most powerful wizards in the land. She earned great renown when, in battle against a superior force from the King's army, she initially escaped capture, swam across a river, and single-handedly began raining lightning down on the enemy camp. She was eventually caught, and lost faith when the Eagle failed to rescue or ransom her. After escaping, she formed her own faction, with loyalties to no one. astonishingly powerful, bold, ambitious
Marty McPolitics 24 28 77 12 98 239 3 91 67 8 77 noProfession FALSE fixed/marty_mcpolitics.png male Marty McPolitics is smart, but no one likes him or trusts him, and for good reason. He'd stab a friend in the back for the smallest advantage, and he's no good in a fight. a loser, fickle, ambitious
Skeletor 95 70 95 100 60 420 2 100 40 30 95 necromancer TRUE fixed/skeletor.png male The Evil Warriors Many stories circulate about Skeletor's origins. Is he the uncle of his arch-nemesis, disfigured after a great battle? Is he an invader from another dimension? Why is he blue, and how did he get so muscle-bound when he seems to be lazy and do everything with magic? But many follow him anyway, for he is enormously powerful and lusts for even more. power-hungry, evil, witty
Shu Lien 86 100 92 71 89 438 98 37 45 99 92 champion TRUE fixed/shu_lien.png female Merchant Protection League Shu Lien is a skilled martial artist. She possess now magical or supernatural abilities; her abilities come from her own training and raw physical power. She once fought in the great wars, but now she runs her own trading company, using her talents to protect her caravans. For the right just cause, though, she might be persuaded to rejoin the fight. noble, sad, strict
Karvax the Fleshless 95 70 95 100 60 420 2 100 40 30 95 necromancer TRUE fixed/karvax_the_fleshless.png male The Hollow Throne No one knows what Karvax looked like before the accident that stripped the flesh from his skull, leaving only bone and sinew held together by dark magic. Some say he was a prince; others say he was always a monster. Whatever the truth, he now commands legions of followers drawn to his raw power and his promise that they too can transcend their mortal limitations. power-hungry, mysterious, darkly witty
Mei Shen 86 100 92 71 89 438 98 37 45 99 92 champion TRUE fixed/mei_shen.png female The Jade Caravan Mei Shen is a martial artist of extraordinary skill, trained from childhood in an ancient discipline. She possesses no magical abilities; her prowess comes entirely from decades of rigorous practice. She once served as a bodyguard to nobles, but now runs her own trading company, personally defending her caravans from bandits. For the right cause, she might be persuaded to fight again. disciplined, melancholy, honorable
Can't render this file because it contains an unexpected character in line 5 and column 5.
@@ -10,10 +10,12 @@ object ApiKeys {
// Environment variable names
private val openAIEnvVar = "OPENAI_API_KEY"
private val anthropicEnvVar = "ANTHROPIC_API_KEY"
private val geminiEnvVar = "GEMINI_API_KEY"
// File key names
private val openAIKeyName = "openai_api_key"
private val anthropicKeyName = "anthropic_api_key"
private val geminiKeyName = "gemini_api_key"
private def readDictionary(): Map[String, String] =
Using(scala.io.Source.fromFile(apiKeyFilePath)) { source =>
@@ -54,4 +56,6 @@ object ApiKeys {
lazy val openAI: String = getKey(openAIKeyName, openAIEnvVar)
lazy val anthropic: String = getKey(anthropicKeyName, anthropicEnvVar)
lazy val gemini: String = getKey(geminiKeyName, geminiEnvVar)
}
@@ -43,6 +43,23 @@ scala_library(
],
)
scala_library(
name = "gemini_service_impl",
srcs = ["GeminiServiceImpl.scala"],
visibility = [
"//visibility:public",
],
deps = [
":api_keys",
":external_text_generation_service_impl",
":rate_limits",
":streaming_text_results",
"@maven//:org_json4s_json4s_ast_3",
"@maven//:org_json4s_json4s_core_3",
"@maven//:org_json4s_json4s_native_3",
],
)
scala_library(
name = "external_text_generation_caller",
srcs = ["ExternalTextGenerationCaller.scala"],
@@ -27,20 +27,12 @@ object ClaudeServiceImpl {
"stream" -> true
)
private def messageVector(
inputText: String,
partialCompletion: Option[String]
): Vector[Map[String, String]] =
private def messageVector(inputText: String): Vector[Map[String, String]] =
Vector(
Map(
"role" -> "user",
"content" -> inputText
)
) ++ partialCompletion.map(pc =>
Map(
"role" -> "assistant",
"content" -> pc
)
)
private def baseRequest(timeoutSeconds: Int): HttpRequest.Builder =
@@ -55,17 +47,15 @@ object ClaudeServiceImpl {
class ClaudeServiceImpl(
val timeoutSeconds: Int = 10,
val defaultModelName: String = "claude-sonnet-4-20250514"
val defaultModelName: String = "claude-3-5-sonnet-20240620"
) extends ExternalTextGenerationServiceImpl {
implicit val jsonFormats: DefaultFormats.type = DefaultFormats
private def requestDictionary(
inputText: String,
partialCompletion: Option[String]
inputText: String
): Map[String, Any] =
dictionaryFor(modelName = defaultModelName) + ("messages" -> messageVector(
inputText,
partialCompletion
inputText
))
override def makeRequest(
@@ -81,7 +71,7 @@ class ClaudeServiceImpl(
.POST(
BodyPublishers.ofString(
Serialization.write(
requestDictionary(inputText = inputText, partialCompletion = partialCompletion)
requestDictionary(inputText = inputText)
)
)
)
@@ -109,9 +99,7 @@ class ClaudeServiceImpl(
completed = false
)
)
case other =>
// May be a different delta type (e.g., tool use), log and skip
println(s"Unexpected content_block_delta format: $other")
case _ => ???
}
case JString("content_block_start") => ()
case JString("content_block_stop") => ()
@@ -130,9 +118,7 @@ class ClaudeServiceImpl(
val errorType = (errorDetails \ "type").extract[String]
val errorMessage = (errorDetails \ "message").extract[String]
println(s"Got an \"($errorType)\" error from Claude: $errorMessage")
case other =>
// Unknown event type, log and skip
println(s"Unknown Claude streaming event type: $other")
case _ => ???
}
}
}
@@ -0,0 +1,179 @@
package net.eagle0.common.llm_integration
import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublishers
import java.net.URI
import java.time.Duration
import java.util.function.Consumer
import org.json4s.{DefaultFormats, JArray, JObject, JString}
import org.json4s.jvalue2extractable
import org.json4s.jvalue2monadic
import org.json4s.native.{Json, Serialization}
object GeminiServiceImpl {
val defaultModel: String = "gemini-2.5-flash-lite"
private val apiKey = ApiKeys.gemini
private val baseURL = "https://generativelanguage.googleapis.com/v1beta/models"
private def requestUrl(modelName: String): String =
s"$baseURL/$modelName:streamGenerateContent?alt=sse&key=$apiKey"
private def contentsFor(
inputText: String,
partialCompletion: Option[String]
): Vector[Map[String, Any]] = {
val userMessage = Map(
"role" -> "user",
"parts" -> Vector(Map("text" -> inputText))
)
partialCompletion match {
case Some(partial) =>
Vector(
userMessage,
Map(
"role" -> "model",
"parts" -> Vector(Map("text" -> partial))
)
)
case None => Vector(userMessage)
}
}
private def baseRequest(
modelName: String,
timeoutSeconds: Int
): HttpRequest.Builder =
HttpRequest
.newBuilder()
.uri(URI.create(requestUrl(modelName)))
.timeout(Duration.ofSeconds(timeoutSeconds))
.header("Content-Type", "application/json")
}
class GeminiServiceImpl(
val timeoutSeconds: Int = 10,
val defaultModelName: String = GeminiServiceImpl.defaultModel
) extends ExternalTextGenerationServiceImpl {
implicit val jsonFormats: DefaultFormats.type = DefaultFormats
private def requestDictionary(
inputText: String,
partialCompletion: Option[String]
): Map[String, Any] =
Map(
"contents" -> GeminiServiceImpl.contentsFor(inputText, partialCompletion)
)
override def makeRequest(
inputText: String,
partialCompletion: Option[String]
): HttpRequest =
GeminiServiceImpl
.baseRequest(modelName = defaultModelName, timeoutSeconds = timeoutSeconds)
.POST(
BodyPublishers.ofString(
Serialization.write(
requestDictionary(
inputText = inputText,
partialCompletion = partialCompletion
)
)
)
)
.build()
private class GeminiStringConsumer(
streamingConsumer: Consumer[StreamingTextResults]
) extends Consumer[String] {
private val json = new Json(DefaultFormats)
// Gemini doesn't provide a stream ID in the same way as OpenAI/Claude,
// so we generate one based on the request
private val streamId = java.util.UUID.randomUUID().toString
override def accept(t: String): Unit = {
val parsedJson =
try
json.parse(t)
catch {
case _: org.json4s.ParserUtil.ParseException =>
// Skip malformed JSON chunks
return
}
// Check for errors first
(parsedJson \ "error") match {
case JObject(errorFields) if errorFields.nonEmpty =>
val errorMessage = (parsedJson \ "error" \ "message").extractOpt[String].getOrElse("Unknown error")
println(s"Gemini API error: $errorMessage")
return
case _ => // No error, continue processing
}
// Extract candidates array
val candidates = (parsedJson \ "candidates") match {
case JArray(arr) => arr
case _ => return // No candidates in this chunk
}
if candidates.isEmpty then return
val firstCandidate = candidates.head.asInstanceOf[JObject]
// Check finish reason
val finishReason = (firstCandidate \ "finishReason") match {
case JString(reason) => Some(reason)
case _ => None
}
val completed = finishReason.exists(r => r == "STOP" || r == "MAX_TOKENS" || r == "SAFETY")
// Extract text content
val textContent = for {
content <- (firstCandidate \ "content").toOption
parts <- (content \ "parts") match {
case JArray(arr) => Some(arr)
case _ => None
}
if parts.nonEmpty
firstPart = parts.head.asInstanceOf[JObject]
text <- (firstPart \ "text") match {
case JString(s) => Some(s)
case _ => None
}
} yield text
textContent match {
case Some(text) =>
streamingConsumer.accept(
StreamingTextResults(
streamId = streamId,
value = text,
completed = completed
)
)
case None =>
// No text content but might be completed
if completed then
streamingConsumer.accept(
StreamingTextResults(
streamId = streamId,
value = "",
completed = true
)
)
}
}
}
override def stringConsumer(
streamingConsumer: Consumer[StreamingTextResults]
): Consumer[String] = new GeminiStringConsumer(streamingConsumer)
// Gemini rate limit headers are different from OpenAI/Anthropic
// For now, return None as rate limit tracking isn't critical
override def rateLimitsFrom(
headers: Map[String, Vector[String]]
): Option[RateLimits] = None
}
@@ -3602,3 +3602,16 @@ scala_setting_library(
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_setting_library(
name = "gemini_model_name",
setting_name = "GeminiModelName",
setting_type = "String",
setting_value = "gemini-2.5-flash-lite",
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/main/scala/net/eagle0/util:__subpackages__",
"//src/test/resources/net/eagle0/shardok/maps:__pkg__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
@@ -118,6 +118,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:food_per_province_held_back",
"//src/main/scala/net/eagle0/eagle/library/settings:free_hero_move_vigor_cost",
"//src/main/scala/net/eagle0/eagle/library/settings:freed_prisoner_loyalty",
"//src/main/scala/net/eagle0/eagle/library/settings:gemini_model_name",
"//src/main/scala/net/eagle0/eagle/library/settings:gift_province_count_exponent",
"//src/main/scala/net/eagle0/eagle/library/settings:gold_cost_for_divine",
"//src/main/scala/net/eagle0/eagle/library/settings:gold_increase_per_economy",
@@ -34,6 +34,7 @@ scala_library(
"//src/main/scala/net/eagle0/common/llm_integration:claude_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_caller",
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:gemini_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:open_ai_chat_completions_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:streaming_text_results",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -48,6 +49,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update_receiver",
"@maven//:com_thesamet_scalapb_scalapb_json4s_3",
"@maven//:io_sentry_sentry",
"@maven//:org_json4s_json4s_ast_3",
"@maven//:org_json4s_json4s_core_3",
"@maven//:org_json4s_json4s_native_3",
@@ -332,11 +334,12 @@ scala_library(
],
deps = [
":llm_update_queuing_proxy",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_response_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/common/llm_integration:claude_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_caller",
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:gemini_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:open_ai_chat_completions_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:streaming_text_results",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
@@ -375,6 +378,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:truce_offer_message_prompt_generator",
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:truce_resolution_message_prompt_generator",
"//src/main/scala/net/eagle0/eagle/library/settings:claude_model_name",
"//src/main/scala/net/eagle0/eagle/library/settings:gemini_model_name",
"//src/main/scala/net/eagle0/eagle/library/settings:llm_provider",
"//src/main/scala/net/eagle0/eagle/library/settings:openai_model_name",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
@@ -390,8 +394,8 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/service:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:llm_response_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
],
)
@@ -403,8 +407,8 @@ scala_library(
],
deps = [
":llm_update_receiver",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_response_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
],
)
@@ -9,10 +9,12 @@ import scala.util.{Failure, Success, Try}
import com.google.protobuf.ByteString
import io.grpc.stub.StreamObserver
import io.sentry.Sentry
import net.eagle0.common.llm_integration.{
ClaudeServiceImpl,
ExternalTextGenerationCaller,
ExternalTextGenerationServiceImpl,
GeminiServiceImpl,
OpenAIChatCompletionsServiceImpl
}
import net.eagle0.common.SimpleTimedLogger
@@ -290,25 +292,45 @@ class GameAdminServiceImpl(
private implicit val ec: ExecutionContext = ExecutionContext.global
private val llmSettingKeys = Set("LlmProvider", "OpenAiModelName", "ClaudeModelName")
private val llmSettingKeys = Set("LlmProvider", "OpenAiModelName", "ClaudeModelName", "GeminiModelName")
private def validateLlmSettings(provider: String, modelName: String): Future[Either[String, Unit]] = {
val testService: ExternalTextGenerationServiceImpl = provider.toLowerCase match {
case "openai" => new OpenAIChatCompletionsServiceImpl(defaultModelName = modelName)
case "claude" => new ClaudeServiceImpl(defaultModelName = modelName)
case _ => return Future.successful(Left(s"Unknown provider: $provider"))
// Wrap service creation in Try to catch API key initialization errors
val testServiceResult: Try[ExternalTextGenerationServiceImpl] = Try {
provider.toLowerCase match {
case "openai" => new OpenAIChatCompletionsServiceImpl(defaultModelName = modelName)
case "claude" => new ClaudeServiceImpl(defaultModelName = modelName)
case "gemini" => new GeminiServiceImpl(defaultModelName = modelName)
case _ => throw new IllegalArgumentException(s"Unknown provider: $provider")
}
}
// Create a caller and make a minimal test request
val testCaller = new ExternalTextGenerationCaller(serviceImpl = testService)
testCaller
.streamCompletion(
inputText = "Say OK",
partialCompletion = None,
streamingConsumer = _ => () // Discard all output
)
.map(_ => Right(()))
.recover { case e: Exception => Left(s"Validation failed: ${e.getMessage}") }
testServiceResult match {
case Failure(e) =>
// Log error including to Sentry - likely missing API key
val errorMessage = e match {
case _: ExceptionInInitializerError =>
val cause = Option(e.getCause).map(_.getMessage).getOrElse("unknown cause")
s"Failed to initialize $provider provider (missing API key?): $cause"
case _ =>
s"Failed to create $provider service: ${e.getMessage}"
}
SimpleTimedLogger.printLogger.logLine(s"LLM validation error: $errorMessage")
Sentry.captureException(e)
Future.successful(Left(errorMessage))
case Success(testService) =>
// Create a caller and make a minimal test request
val testCaller = new ExternalTextGenerationCaller(serviceImpl = testService)
testCaller
.streamCompletion(
inputText = "Say OK",
partialCompletion = None,
streamingConsumer = _ => () // Discard all output
)
.map(_ => Right(()))
.recover { case e: Exception => Left(s"Validation failed: ${e.getMessage}") }
}
}
override def addSettings(
@@ -333,6 +355,11 @@ class GameAdminServiceImpl(
"ClaudeModelName",
currentSettings.getOrElse("ClaudeModelName", "claude-sonnet-4-20250514")
)
case "gemini" =>
settingsMap.getOrElse(
"GeminiModelName",
currentSettings.getOrElse("GeminiModelName", "gemini-2.5-flash-lite")
)
case _ => ""
}
@@ -14,9 +14,7 @@ import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest.{Shardok
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.client_text.{ClientTextStoreImpl, PregeneratedClientTextStore, UnrequestedClientText}
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.llm_response.LLMResponse
import net.eagle0.eagle.internal.running_games.{RunningGame, RunningGames}
import net.eagle0.eagle.internal.shardok_battle as sb_proto
import net.eagle0.eagle.library.*
@@ -27,6 +25,7 @@ import net.eagle0.eagle.library.util.hero_generator.HeroGenerator
import net.eagle0.eagle.library.util.EagleRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedHeroName
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedTextRequestT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.hero.ProfessionConverter
import net.eagle0.eagle.model.proto_converters.shardok_battle.ShardokBattleConverter
@@ -272,7 +271,8 @@ class GamesManager(
new ShardokInterfaceGrpcClient(shardokInternalInterface, this)
private val llmResolver = new LlmResolver(
updateReceiver = new LlmUpdateQueuingProxy(this)
updateReceiver = new LlmUpdateQueuingProxy(this),
isGameValid = gameId => gameControllerInfos.contains(gameId)
)
private val unrequestedTextHandler = new UnrequestedTextHandler(
@@ -553,10 +553,11 @@ class GamesManager(
override def receiveStreamingLlmResponses(
streamId: String,
llmResponses: Vector[LLMResponse],
llmRequest: GeneratedTextRequestT,
responseTexts: Vector[String],
completed: Boolean
): Unit = this.synchronized {
val gameId = llmResponses.head.llmRequest.get.eagleGameId
val gameId = llmRequest.eagleGameId
// Game may have been deleted while LLM request was in flight (e.g., warmup games)
gameControllerInfos.get(gameId) match {
@@ -569,7 +570,7 @@ class GamesManager(
case Some(controllerInfo) =>
synchronizedHandlePostResults(
controllerInfo.controller
.postStreamingLlmUpdates(streamId, llmResponses, completed)
.postStreamingLlmUpdates(streamId, llmRequest.requestId, responseTexts, completed)
).postResults
if completed then {
@@ -581,7 +582,7 @@ class GamesManager(
}
override def receiveStreamingLlmFailure(
llmRequest: GeneratedTextRequest
llmRequest: GeneratedTextRequestT
): Unit = this.synchronized {
val gameId = llmRequest.eagleGameId
@@ -595,12 +596,12 @@ class GamesManager(
case Some(controllerInfo) =>
synchronizedHandlePostResults(
controllerInfo.controller
.postStreamingLlmFailure(llmRequest)
.postStreamingLlmFailure(llmRequest.requestId)
).postResults
}
}
override def aiPlayers(llmRequest: GeneratedTextRequest): Vector[FactionId] =
override def aiPlayers(llmRequest: GeneratedTextRequestT): Vector[FactionId] =
// Game may have been deleted while LLM request was in flight (e.g., warmup games)
gameControllerInfos.get(llmRequest.eagleGameId) match {
case None => Vector.empty
@@ -9,6 +9,7 @@ import net.eagle0.common.{FunctionalRandom, RandomState, SimpleTimedLogger}
import net.eagle0.common.llm_integration.{
ClaudeServiceImpl,
ExternalTextGenerationCaller,
GeminiServiceImpl,
OpenAIChatCompletionsServiceImpl
}
import net.eagle0.eagle.client_text.{
@@ -19,7 +20,6 @@ import net.eagle0.eagle.client_text.{
TextGenerationResult,
TextGenerationSuccess
}
import net.eagle0.eagle.internal.llm_response.LLMResponse
import net.eagle0.eagle.library.actions.llm_prompt_generators.{
AllianceOfferMessagePromptGenerator,
AllianceResolutionMessagePromptGenerator,
@@ -56,7 +56,7 @@ import net.eagle0.eagle.library.actions.llm_prompt_generators.{
TruceOfferMessagePromptGenerator,
TruceResolutionMessagePromptGenerator
}
import net.eagle0.eagle.library.settings.{ClaudeModelName, LlmProvider, OpenAiModelName}
import net.eagle0.eagle.library.settings.{ClaudeModelName, GeminiModelName, LlmProvider, OpenAiModelName}
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.action_result.generated_text_request.{
FixedHeroName,
@@ -66,12 +66,14 @@ import net.eagle0.eagle.model.action_result.generated_text_request.{
}
import net.eagle0.eagle.model.proto_converters.GeneratedTextRequestConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.GameId
sealed trait LlmResolverResult
case class LlmResolverDependencyNotSatisfied(notSatisfiedTextId: String) extends LlmResolverResult
case object LlmResolverTooManyRequestsInFlight extends LlmResolverResult
case object LlmResolverBypassed extends LlmResolverResult
case class LlmResolverRequested(future: Future[?]) extends LlmResolverResult
case object LlmResolverGameDeleted extends LlmResolverResult
object LlmResolver {
case class LlmRequestWithGameState(
@@ -81,7 +83,7 @@ object LlmResolver {
)
}
class LlmResolver(updateReceiver: LlmUpdateQueuingProxy) {
class LlmResolver(updateReceiver: LlmUpdateQueuingProxy, isGameValid: GameId => Boolean) {
import LlmResolver.LlmRequestWithGameState
implicit val ec: ExecutionContext = ExecutionContext.global
@@ -102,6 +104,12 @@ class LlmResolver(updateReceiver: LlmUpdateQueuingProxy) {
serviceImpl = new ClaudeServiceImpl(defaultModelName = ClaudeModelName.stringValue)
)
}.toVector
case "gemini" =>
(1 to callerCount).map { _ =>
new ExternalTextGenerationCaller(
serviceImpl = new GeminiServiceImpl(defaultModelName = GeminiModelName.stringValue)
)
}.toVector
case provider =>
throw new IllegalArgumentException(s"Unknown LLM provider: $provider")
}
@@ -200,75 +208,75 @@ class LlmResolver(updateReceiver: LlmUpdateQueuingProxy) {
maybePrompt: TextGenerationResult,
partialCompletion: Option[String]
) =>
// Convert to proto for boundaries that need it
val llmRequestProto = GeneratedTextRequestConverter.toProto(llmRequest)
(
llmRequest,
maybePrompt match {
case TextGenerationSuccess(prompt) =>
if prompt.contains("TextGeneration") || prompt
.contains("Option[") ||
prompt.contains("RandomState[")
then {
print(
s"Bad prompt for LLM request ${llmRequest.requestId}:\n$prompt\n"
)
}
llmCallers
.filter(x => x.getInProgressCount < x.maxConcurrentStreams)
.minByOption(_.getInProgressCount)
.map { chosenCaller =>
log(llmRequest, prompt)
LlmResolverRequested {
chosenCaller
.streamCompletion(
inputText = prompt,
partialCompletion = partialCompletion,
streamingConsumer = streamingResults =>
updateReceiver.receiveStreamingLlmResponse(
streamId = streamingResults.streamId,
llmResponse = LLMResponse(
llmRequest = Some(llmRequestProto),
responseText = streamingResults.value
),
completed = streamingResults.completed
)
)
.recover {
case ex =>
// Log the failure for debugging and send to Sentry
SimpleTimedLogger.printLogger.logLine(
s"LLM processing failed for request ${llmRequest.requestId}: $ex"
)
ex.printStackTrace()
Sentry.captureException(ex)
// Handle failure by moving request back to unrequested state for retry
updateReceiver.receiveStreamingLlmFailure(
llmRequest = llmRequestProto
)
throw ex // Re-throw to maintain error semantics
}
}
// Check if game still exists before sending the request
// This prevents wasted LLM calls for games that were deleted (e.g., warmup games
// during blue-green deployments)
if !isGameValid(llmRequest.eagleGameId) then {
(llmRequest, LlmResolverGameDeleted)
} else {
(
llmRequest,
maybePrompt match {
case TextGenerationSuccess(prompt) =>
if prompt.contains("TextGeneration") || prompt
.contains("Option[") ||
prompt.contains("RandomState[")
then {
print(
s"Bad prompt for LLM request ${llmRequest.requestId}:\n$prompt\n"
)
}
.getOrElse(LlmResolverTooManyRequestsInFlight)
case TextGenerationDependencyInProgress(
notSatisfiedTextId,
_ /* partialText */
) =>
LlmResolverDependencyNotSatisfied(notSatisfiedTextId)
case TextGenerationDependencyWaiting(notSatisfiedTextId) =>
LlmResolverDependencyNotSatisfied(notSatisfiedTextId)
llmCallers
.filter(x => x.getInProgressCount < x.maxConcurrentStreams)
.minByOption(_.getInProgressCount)
.map { chosenCaller =>
log(llmRequest, prompt)
case TextGenerationDependencyUnknown(notSatisfiedTextId) =>
throw new EagleInternalException(
s"Unknown dependency for LLM request ${llmRequest.requestId}: $notSatisfiedTextId"
)
}
)
LlmResolverRequested {
chosenCaller
.streamCompletion(
inputText = prompt,
partialCompletion = partialCompletion,
streamingConsumer = streamingResults =>
updateReceiver.receiveStreamingLlmResponse(
streamId = streamingResults.streamId,
llmRequest = llmRequest,
responseText = streamingResults.value,
completed = streamingResults.completed
)
)
.recover {
case ex =>
// Log the failure for debugging and send to Sentry
SimpleTimedLogger.printLogger.logLine(
s"LLM processing failed for request ${llmRequest.requestId}: $ex"
)
ex.printStackTrace()
Sentry.captureException(ex)
// Handle failure by moving request back to unrequested state for retry
updateReceiver.receiveStreamingLlmFailure(llmRequest)
throw ex // Re-throw to maintain error semantics
}
}
}
.getOrElse(LlmResolverTooManyRequestsInFlight)
case TextGenerationDependencyInProgress(
notSatisfiedTextId,
_ /* partialText */
) =>
LlmResolverDependencyNotSatisfied(notSatisfiedTextId)
case TextGenerationDependencyWaiting(notSatisfiedTextId) =>
LlmResolverDependencyNotSatisfied(notSatisfiedTextId)
case TextGenerationDependencyUnknown(notSatisfiedTextId) =>
throw new EagleInternalException(
s"Unknown dependency for LLM request ${llmRequest.requestId}: $notSatisfiedTextId"
)
}
)
}
}
}
@@ -1,6 +1,6 @@
package net.eagle0.eagle.service
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
import net.eagle0.eagle.internal.llm_response.LLMResponse
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedTextRequestT
import net.eagle0.eagle.FactionId
class LlmUpdateQueuingProxy(destination: LlmUpdateReceiver) {
@@ -8,11 +8,12 @@ class LlmUpdateQueuingProxy(destination: LlmUpdateReceiver) {
private sealed trait QueuedLlmUpdate
private case class StreamingLlmResponse(
streamId: String,
llmResponse: LLMResponse,
llmRequest: GeneratedTextRequestT,
responseText: String,
completed: Boolean
) extends QueuedLlmUpdate
private case class StreamingLlmFailure(
llmRequest: GeneratedTextRequest
llmRequest: GeneratedTextRequestT
) extends QueuedLlmUpdate
private val queue = new scala.collection.mutable.Queue[QueuedLlmUpdate]
@@ -21,20 +22,21 @@ class LlmUpdateQueuingProxy(destination: LlmUpdateReceiver) {
def receiveStreamingLlmResponse(
streamId: String,
llmResponse: LLMResponse,
llmRequest: GeneratedTextRequestT,
responseText: String,
completed: Boolean
): Unit = queue.synchronized {
queue.enqueue(StreamingLlmResponse(streamId, llmResponse, completed))
queue.enqueue(StreamingLlmResponse(streamId, llmRequest, responseText, completed))
queue.notifyAll()
}
def receiveStreamingLlmFailure(llmRequest: GeneratedTextRequest): Unit =
def receiveStreamingLlmFailure(llmRequest: GeneratedTextRequestT): Unit =
queue.synchronized {
queue.enqueue(StreamingLlmFailure(llmRequest))
queue.notifyAll()
}
def aiPlayers(llmRequest: GeneratedTextRequest): Vector[FactionId] =
def aiPlayers(llmRequest: GeneratedTextRequestT): Vector[FactionId] =
destination.aiPlayers(llmRequest)
private class Consumer extends Runnable {
@@ -63,17 +65,18 @@ class LlmUpdateQueuingProxy(destination: LlmUpdateReceiver) {
case (streamId, messages) =>
destination.receiveStreamingLlmResponses(
streamId,
messages.map(_.llmResponse),
messages.last.completed
llmRequest = messages.head.llmRequest,
responseTexts = messages.map(_.responseText),
completed = messages.last.completed
)
}
} catch {
case e: Exception =>
val updateIds = updates.map {
case StreamingLlmResponse(streamId, llmResponse, _) =>
s"response:${llmResponse.llmRequest.map(_.id).getOrElse("unknown")}"
case StreamingLlmFailure(llmRequest) =>
s"failure:${llmRequest.id}"
case StreamingLlmResponse(_, llmRequest, _, _) =>
s"response:${llmRequest.requestId}"
case StreamingLlmFailure(llmRequest) =>
s"failure:${llmRequest.requestId}"
}.mkString(", ")
System.err.println(
s"ERROR: LlmUpdateQueuingProxy consumer caught exception processing updates [$updateIds]: ${e.getMessage}"
@@ -1,15 +1,16 @@
package net.eagle0.eagle.service
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
import net.eagle0.eagle.internal.llm_response.LLMResponse
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedTextRequestT
import net.eagle0.eagle.FactionId
/** Receives LLM streaming updates. Uses Scala domain types instead of protos. */
trait LlmUpdateReceiver {
def receiveStreamingLlmResponses(
streamId: String,
llmResponses: Vector[LLMResponse],
llmRequest: GeneratedTextRequestT,
responseTexts: Vector[String],
completed: Boolean
): Unit
def receiveStreamingLlmFailure(llmRequest: GeneratedTextRequest): Unit
def aiPlayers(llmRequest: GeneratedTextRequest): Vector[FactionId]
def receiveStreamingLlmFailure(llmRequest: GeneratedTextRequestT): Unit
def aiPlayers(llmRequest: GeneratedTextRequestT): Vector[FactionId]
}
@@ -125,6 +125,10 @@ class UnrequestedTextHandler(llmResolver: LlmResolver) {
)
case LlmResolverTooManyRequestsInFlight =>
acc
case LlmResolverGameDeleted =>
// Game was deleted (e.g., warmup game during blue-green deployment)
// Just skip the request - it will be cleaned up when the game's data is deleted
acc
case LlmResolverDependencyNotSatisfied(unsatisfiedTextId) =>
acc.copy(stuckEntries =
acc.stuckEntries :+ StuckUnrequested(
@@ -205,6 +209,9 @@ class UnrequestedTextHandler(llmResolver: LlmResolver) {
s"Moving stalled incomplete text ${llmRequest.requestId} back to unrequested due to unsatisfied dependency"
)
cts.withMovedBackToUnrequested(llmRequest.requestId)
case (cts, (_, LlmResolverGameDeleted)) =>
// Game was deleted - just skip, texts will be cleaned up with the game
cts
case (cts, _) =>
cts
}
@@ -16,8 +16,6 @@ import net.eagle0.eagle.api.eagle.ShardokActionResultResponse.SingleShardokGameR
import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest.{ShardokViewStatus, StreamingTextStatus}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.client_text.{ClientText, ClientTextStore, ClientTextStoreWithUpdate}
import net.eagle0.eagle.internal.generated_text_request.GeneratedTextRequest
import net.eagle0.eagle.internal.llm_response.LLMResponse
import net.eagle0.eagle.library.{EagleInternalException, Engine, EngineAndResults}
import net.eagle0.eagle.library.actions.availability.AvailableCommandsFactory
import net.eagle0.eagle.library.util.CommandSelection
@@ -391,13 +389,14 @@ final case class GameController(
private def postOneLlmResponse(
streamId: String,
llmResponse: LLMResponse,
requestId: String,
responseText: String,
completed: Boolean
): GameController =
this.clientTextStore
.withAppendedText(
id = llmResponse.llmRequest.get.id,
newText = llmResponse.responseText,
id = requestId,
newText = responseText,
complete = completed
) match {
case ClientTextStoreWithUpdate(cts, updatedText) =>
@@ -419,21 +418,18 @@ final case class GameController(
def postStreamingLlmUpdates(
streamId: String,
llmResponses: Vector[LLMResponse],
requestId: String,
responseTexts: Vector[String],
completed: Boolean
): GameControllerWithPostResults = {
val requestId = llmResponses.head.llmRequest.get.id
internalRequire(
llmResponses.forall(_.llmRequest.get.id == requestId),
"All LLM responses must have the same ID"
)
val controllerWithHandledStreams = llmResponses match {
internalRequire(responseTexts.nonEmpty, "responseTexts is empty")
val controllerWithHandledStreams = responseTexts match {
case h :+ t =>
h.foldLeft(this) {
case (innerC, llmResponse) =>
innerC.postOneLlmResponse(streamId, llmResponse, completed = false)
}.postOneLlmResponse(streamId, t, completed)
case _ => throw new EagleInternalException("llmResponses is empty")
case (innerC, responseText) =>
innerC.postOneLlmResponse(streamId, requestId, responseText, completed = false)
}.postOneLlmResponse(streamId, requestId, t, completed)
case _ => throw new EagleInternalException("responseTexts is empty")
}
val currentState = controllerWithHandledStreams.engine.currentState
@@ -448,10 +444,10 @@ final case class GameController(
}
def postStreamingLlmFailure(
llmRequest: GeneratedTextRequest
requestId: String
): GameControllerWithPostResults = {
val updatedClientTextStore =
clientTextStore.withMovedBackToUnrequested(llmRequest.id)
clientTextStore.withMovedBackToUnrequested(requestId)
val currentState = engine.currentState
GameControllerWithPostResults(
@@ -110,7 +110,6 @@ scala_test(
timeout = "short",
srcs = ["LlmResolverTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:llm_response_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
@@ -14,7 +14,7 @@ import org.scalatest.matchers.should.Matchers.*
class LlmResolverTest extends AnyFlatSpec with MockFactory {
private val mockReceiver = mock[LlmUpdateQueuingProxy]
private val llmResolver =
new LlmResolver(updateReceiver = mockReceiver)
new LlmResolver(updateReceiver = mockReceiver, isGameValid = _ => true)
private val firstRequest: GeneratedTextRequestT = LlmRequestT.DivineMessage(
requestId = "firstRequest",