mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 04:05:44 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6882fabc95 | ||
|
|
84c1bf61b0 | ||
|
|
21dfa12197 | ||
|
|
de4c568aa0 | ||
|
|
edccde9a90 | ||
|
|
0f7f019f14 | ||
|
|
adcafe3224 | ||
|
|
89ad8371b5 | ||
|
|
b8c584572d | ||
|
|
acb411c4d2 | ||
|
|
25f92c7832 | ||
|
|
0f0d2433bb | ||
|
|
eea61f1c6a |
@@ -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}"
|
||||
|
||||
|
||||
@@ -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:-}"
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Executable
+43
@@ -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" "$@"
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user