kikikita commited on
Commit
e2d35c0
·
1 Parent(s): d696848

feat: implement NPC model switching and admin API for model management

Browse files
config/game.llm.local.json CHANGED
@@ -56,11 +56,11 @@
56
  "base_url": "https://georgij-sawin--world-simulator-qwen36-27b-h100-serve.modal.run/v1",
57
  "model": "qwen3.6-27b-h100-fp8",
58
  "timeout_seconds": 600,
59
- "max_tokens": 1200,
60
  "temperature": 0.6,
61
  "top_p": 0.95,
62
- "tool_choice": "required",
63
- "max_parallel_npc_calls": 6,
64
  "fallback_to_deterministic": true,
65
  "extra_body": {
66
  "chat_template_kwargs": {
 
56
  "base_url": "https://georgij-sawin--world-simulator-qwen36-27b-h100-serve.modal.run/v1",
57
  "model": "qwen3.6-27b-h100-fp8",
58
  "timeout_seconds": 600,
59
+ "max_tokens": 256,
60
  "temperature": 0.6,
61
  "top_p": 0.95,
62
+ "tool_choice": "auto",
63
+ "max_parallel_npc_calls": 2,
64
  "fallback_to_deterministic": true,
65
  "extra_body": {
66
  "chat_template_kwargs": {
config/game.modal.local.json CHANGED
@@ -60,11 +60,11 @@
60
  "base_url": "https://georgij-sawin--world-simulator-qwen36-27b-h100-serve.modal.run/v1",
61
  "model": "qwen3.6-27b-h100-fp8",
62
  "timeout_seconds": 600,
63
- "max_tokens": 1200,
64
  "temperature": 0.6,
65
  "top_p": 0.95,
66
- "tool_choice": "required",
67
- "max_parallel_npc_calls": 6,
68
  "fallback_to_deterministic": true,
69
  "extra_body": {
70
  "chat_template_kwargs": {
 
60
  "base_url": "https://georgij-sawin--world-simulator-qwen36-27b-h100-serve.modal.run/v1",
61
  "model": "qwen3.6-27b-h100-fp8",
62
  "timeout_seconds": 600,
63
+ "max_tokens": 256,
64
  "temperature": 0.6,
65
  "top_p": 0.95,
66
+ "tool_choice": "auto",
67
+ "max_parallel_npc_calls": 2,
68
  "fallback_to_deterministic": true,
69
  "extra_body": {
70
  "chat_template_kwargs": {
frontend/src/App.tsx CHANGED
@@ -45,7 +45,12 @@ export function App() {
45
  resource={simulation.selectedResource}
46
  house={simulation.selectedHouse}
47
  entities={simulation.snapshot?.entities ?? []}
 
 
48
  onSelectEntity={simulation.setSelectedId}
 
 
 
49
  />
50
 
51
  <StatusToasts
 
45
  resource={simulation.selectedResource}
46
  house={simulation.selectedHouse}
47
  entities={simulation.snapshot?.entities ?? []}
48
+ modelProfiles={simulation.modelProfiles}
49
+ isModelSwitchPending={simulation.isModelSwitchPending}
50
  onSelectEntity={simulation.setSelectedId}
51
+ onSetNpcModel={(npcId, profileId) => {
52
+ void simulation.setNpcModel(npcId, profileId);
53
+ }}
54
  />
55
 
56
  <StatusToasts
frontend/src/api.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { WorldSnapshot } from "./types";
2
 
3
  const API_BASE = import.meta.env.VITE_WORLD_SIMULATOR_API ?? defaultApiBase();
 
4
 
5
  function defaultApiBase(): string {
6
  if (window.location.hostname === "127.0.0.1" && window.location.port === "5173") {
@@ -41,3 +42,42 @@ export async function tickWorld(): Promise<void> {
41
  throw new ApiResponseError(`World tick failed: ${response.status}`, response.status);
42
  }
43
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AdminModelsSnapshot, WorldSnapshot } from "./types";
2
 
3
  const API_BASE = import.meta.env.VITE_WORLD_SIMULATOR_API ?? defaultApiBase();
4
+ const ADMIN_TOKEN_STORAGE_KEY = "world-simulator-admin-token";
5
 
6
  function defaultApiBase(): string {
7
  if (window.location.hostname === "127.0.0.1" && window.location.port === "5173") {
 
42
  throw new ApiResponseError(`World tick failed: ${response.status}`, response.status);
43
  }
44
  }
45
+
46
+ export async function fetchAdminModels(signal?: AbortSignal): Promise<AdminModelsSnapshot> {
47
+ const response = await fetch(`${API_BASE}/admin/models`, {
48
+ headers: adminHeaders(),
49
+ signal,
50
+ });
51
+ if (!response.ok) {
52
+ throw new ApiResponseError(`Model profiles failed: ${response.status}`, response.status);
53
+ }
54
+ return (await response.json()) as AdminModelsSnapshot;
55
+ }
56
+
57
+ export async function setNpcModelProfile(npcId: string, profileId: string): Promise<void> {
58
+ const response = await fetch(`${API_BASE}/admin/npcs/${encodeURIComponent(npcId)}/model`, {
59
+ method: "POST",
60
+ headers: {
61
+ "Content-Type": "application/json",
62
+ ...adminHeaders(),
63
+ },
64
+ body: JSON.stringify({ profile_id: profileId }),
65
+ });
66
+ if (!response.ok) {
67
+ throw new ApiResponseError(`NPC model switch failed: ${response.status}`, response.status);
68
+ }
69
+ }
70
+
71
+ function adminHeaders(): Record<string, string> {
72
+ return {
73
+ "X-Admin-Token": adminToken(),
74
+ };
75
+ }
76
+
77
+ function adminToken(): string {
78
+ return (
79
+ import.meta.env.VITE_WORLD_SIMULATOR_ADMIN_TOKEN ||
80
+ window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY) ||
81
+ "dev-admin-token"
82
+ );
83
+ }
frontend/src/components/AgentPanel.tsx CHANGED
@@ -2,6 +2,7 @@ import type {
2
  BeastSnapshot,
3
  EntitySnapshot,
4
  HouseSnapshot,
 
5
  ResourceNodeSnapshot,
6
  } from "../types";
7
  import { TooltipLabel } from "./TooltipLabel";
@@ -12,7 +13,10 @@ type AgentPanelProps = {
12
  resource?: ResourceNodeSnapshot | null;
13
  house?: HouseSnapshot | null;
14
  entities?: EntitySnapshot[];
 
 
15
  onSelectEntity?: (id: string) => void;
 
16
  };
17
 
18
  export function AgentPanel({
@@ -21,7 +25,10 @@ export function AgentPanel({
21
  resource = null,
22
  house = null,
23
  entities = [],
 
 
24
  onSelectEntity,
 
25
  }: AgentPanelProps) {
26
  if (beast) {
27
  return <BeastPanel beast={beast} />;
@@ -44,7 +51,16 @@ export function AgentPanel({
44
  <TooltipLabel className="panelTitle" value={selectedName} />
45
  <TooltipLabel className="panelRole" value={selectedRole} />
46
  </div>
47
- {entity ? <AgentDetails entity={entity} /> : <EmptyAgentReadout />}
 
 
 
 
 
 
 
 
 
48
  </aside>
49
  );
50
  }
@@ -185,10 +201,28 @@ function HouseResidentList({ label, ids, emptyLabel, entities, onSelectEntity }:
185
  );
186
  }
187
 
188
- function AgentDetails({ entity }: { entity: EntitySnapshot }) {
 
 
 
 
 
 
 
 
 
 
 
 
189
  return (
190
  <>
191
  <AgentReadout entity={entity} />
 
 
 
 
 
 
192
  <SurvivalReadout entity={entity} />
193
  <RelationshipList entity={entity} />
194
  <MemoryList entity={entity} />
@@ -196,6 +230,39 @@ function AgentDetails({ entity }: { entity: EntitySnapshot }) {
196
  );
197
  }
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  function SurvivalReadout({ entity }: { entity: EntitySnapshot }) {
200
  const { hunger, fear, safety, age, max_age, importance, goal, inventory } = entity.state;
201
  if (
 
2
  BeastSnapshot,
3
  EntitySnapshot,
4
  HouseSnapshot,
5
+ ModelProfileSnapshot,
6
  ResourceNodeSnapshot,
7
  } from "../types";
8
  import { TooltipLabel } from "./TooltipLabel";
 
13
  resource?: ResourceNodeSnapshot | null;
14
  house?: HouseSnapshot | null;
15
  entities?: EntitySnapshot[];
16
+ modelProfiles?: ModelProfileSnapshot[];
17
+ isModelSwitchPending?: boolean;
18
  onSelectEntity?: (id: string) => void;
19
+ onSetNpcModel?: (npcId: string, profileId: string) => void;
20
  };
21
 
22
  export function AgentPanel({
 
25
  resource = null,
26
  house = null,
27
  entities = [],
28
+ modelProfiles = [],
29
+ isModelSwitchPending = false,
30
  onSelectEntity,
31
+ onSetNpcModel,
32
  }: AgentPanelProps) {
33
  if (beast) {
34
  return <BeastPanel beast={beast} />;
 
51
  <TooltipLabel className="panelTitle" value={selectedName} />
52
  <TooltipLabel className="panelRole" value={selectedRole} />
53
  </div>
54
+ {entity ? (
55
+ <AgentDetails
56
+ entity={entity}
57
+ modelProfiles={modelProfiles}
58
+ isModelSwitchPending={isModelSwitchPending}
59
+ onSetNpcModel={onSetNpcModel}
60
+ />
61
+ ) : (
62
+ <EmptyAgentReadout />
63
+ )}
64
  </aside>
65
  );
66
  }
 
201
  );
202
  }
203
 
204
+ type AgentDetailsProps = {
205
+ entity: EntitySnapshot;
206
+ modelProfiles: ModelProfileSnapshot[];
207
+ isModelSwitchPending: boolean;
208
+ onSetNpcModel?: (npcId: string, profileId: string) => void;
209
+ };
210
+
211
+ function AgentDetails({
212
+ entity,
213
+ modelProfiles,
214
+ isModelSwitchPending,
215
+ onSetNpcModel,
216
+ }: AgentDetailsProps) {
217
  return (
218
  <>
219
  <AgentReadout entity={entity} />
220
+ <ModelSwitcher
221
+ entity={entity}
222
+ profiles={modelProfiles}
223
+ isPending={isModelSwitchPending}
224
+ onSetNpcModel={onSetNpcModel}
225
+ />
226
  <SurvivalReadout entity={entity} />
227
  <RelationshipList entity={entity} />
228
  <MemoryList entity={entity} />
 
230
  );
231
  }
232
 
233
+ type ModelSwitcherProps = {
234
+ entity: EntitySnapshot;
235
+ profiles: ModelProfileSnapshot[];
236
+ isPending: boolean;
237
+ onSetNpcModel?: (npcId: string, profileId: string) => void;
238
+ };
239
+
240
+ function ModelSwitcher({ entity, profiles, isPending, onSetNpcModel }: ModelSwitcherProps) {
241
+ if (profiles.length === 0) {
242
+ return null;
243
+ }
244
+
245
+ const currentProfile = entity.model_profile_id ?? entity.connector_id ?? "default";
246
+
247
+ return (
248
+ <label className="modelControl">
249
+ <span>Model</span>
250
+ <select
251
+ value={currentProfile}
252
+ disabled={isPending || !onSetNpcModel}
253
+ onChange={(event) => onSetNpcModel?.(entity.id, event.currentTarget.value)}
254
+ >
255
+ {profiles.map((profile) => (
256
+ <option key={profile.id} value={profile.id}>
257
+ {profile.label || profile.id}
258
+ {profile.model ? ` - ${profile.model}` : ""}
259
+ </option>
260
+ ))}
261
+ </select>
262
+ </label>
263
+ );
264
+ }
265
+
266
  function SurvivalReadout({ entity }: { entity: EntitySnapshot }) {
267
  const { hunger, fear, safety, age, max_age, importance, goal, inventory } = entity.state;
268
  if (
frontend/src/hooks/useWorldSimulation.ts CHANGED
@@ -1,12 +1,20 @@
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
 
3
- import { ApiResponseError, fetchWorldSnapshot, tickWorld } from "../api";
4
- import type { GameLogEventSnapshot, WorldSnapshot } from "../types";
 
 
 
 
 
 
5
 
6
  export function useWorldSimulation() {
7
  const [snapshot, setSnapshot] = useState<WorldSnapshot | null>(null);
8
  const [isTickPending, setIsTickPending] = useState(false);
9
  const [isAutoTicking, setIsAutoTicking] = useState(false);
 
 
10
  const [error, setError] = useState<string | null>(null);
11
  const [selectedId, setSelectedId] = useState<string | null>(null);
12
  const isTickingRef = useRef(false);
@@ -47,7 +55,30 @@ export function useWorldSimulation() {
47
  }
48
  }, [refresh, snapshot?.simulation.tick_in_progress]);
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  useInitialSnapshot(setSnapshot, setSelectedId, setError);
 
51
  useSelectedEntityFallback(snapshot, setSelectedId);
52
  useServerTickPolling(isServerTickPending, refresh, setError);
53
  useIdleSnapshotPolling({
@@ -90,20 +121,42 @@ export function useWorldSimulation() {
90
  error,
91
  eventHistory,
92
  isAutoTicking,
 
93
  isWaitingForTick,
94
  isWorldCommandPending,
 
95
  selectedBeast,
96
  selectedEntity,
97
  selectedHouse,
98
  selectedResource,
99
  selectedId,
100
  setIsAutoTicking,
 
101
  setSelectedId,
102
  snapshot,
103
  step,
104
  };
105
  }
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  function useEventHistory(snapshot: WorldSnapshot | null): GameLogEventSnapshot[] {
108
  const seenKeysRef = useRef<Set<string>>(new Set());
109
  const [history, setHistory] = useState<GameLogEventSnapshot[]>([]);
 
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
 
3
+ import {
4
+ ApiResponseError,
5
+ fetchAdminModels,
6
+ fetchWorldSnapshot,
7
+ setNpcModelProfile,
8
+ tickWorld,
9
+ } from "../api";
10
+ import type { GameLogEventSnapshot, ModelProfileSnapshot, WorldSnapshot } from "../types";
11
 
12
  export function useWorldSimulation() {
13
  const [snapshot, setSnapshot] = useState<WorldSnapshot | null>(null);
14
  const [isTickPending, setIsTickPending] = useState(false);
15
  const [isAutoTicking, setIsAutoTicking] = useState(false);
16
+ const [isModelSwitchPending, setIsModelSwitchPending] = useState(false);
17
+ const [modelProfiles, setModelProfiles] = useState<ModelProfileSnapshot[]>([]);
18
  const [error, setError] = useState<string | null>(null);
19
  const [selectedId, setSelectedId] = useState<string | null>(null);
20
  const isTickingRef = useRef(false);
 
55
  }
56
  }, [refresh, snapshot?.simulation.tick_in_progress]);
57
 
58
+ const refreshModelProfiles = useCallback(async () => {
59
+ const adminModels = await fetchAdminModels();
60
+ setModelProfiles(adminModels.profiles);
61
+ }, []);
62
+
63
+ const setNpcModel = useCallback(
64
+ async (npcId: string, profileId: string) => {
65
+ setIsModelSwitchPending(true);
66
+ setError(null);
67
+ try {
68
+ await setNpcModelProfile(npcId, profileId);
69
+ await refresh();
70
+ await refreshModelProfiles();
71
+ } catch (nextError) {
72
+ setError(nextError instanceof Error ? nextError.message : "NPC model switch failed");
73
+ } finally {
74
+ setIsModelSwitchPending(false);
75
+ }
76
+ },
77
+ [refresh, refreshModelProfiles],
78
+ );
79
+
80
  useInitialSnapshot(setSnapshot, setSelectedId, setError);
81
+ useInitialModelProfiles(setModelProfiles);
82
  useSelectedEntityFallback(snapshot, setSelectedId);
83
  useServerTickPolling(isServerTickPending, refresh, setError);
84
  useIdleSnapshotPolling({
 
121
  error,
122
  eventHistory,
123
  isAutoTicking,
124
+ isModelSwitchPending,
125
  isWaitingForTick,
126
  isWorldCommandPending,
127
+ modelProfiles,
128
  selectedBeast,
129
  selectedEntity,
130
  selectedHouse,
131
  selectedResource,
132
  selectedId,
133
  setIsAutoTicking,
134
+ setNpcModel,
135
  setSelectedId,
136
  snapshot,
137
  step,
138
  };
139
  }
140
 
141
+ function useInitialModelProfiles(
142
+ setModelProfiles: (profiles: ModelProfileSnapshot[]) => void,
143
+ ) {
144
+ useEffect(() => {
145
+ const abortController = new AbortController();
146
+ fetchAdminModels(abortController.signal)
147
+ .then((adminModels) => {
148
+ setModelProfiles(adminModels.profiles);
149
+ })
150
+ .catch(() => {
151
+ if (!abortController.signal.aborted) {
152
+ setModelProfiles([]);
153
+ }
154
+ });
155
+
156
+ return () => abortController.abort();
157
+ }, [setModelProfiles]);
158
+ }
159
+
160
  function useEventHistory(snapshot: WorldSnapshot | null): GameLogEventSnapshot[] {
161
  const seenKeysRef = useRef<Set<string>>(new Set());
162
  const [history, setHistory] = useState<GameLogEventSnapshot[]>([]);
frontend/src/styles.css CHANGED
@@ -538,6 +538,38 @@ button {
538
  font-weight: 750;
539
  }
540
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
  .directiveBanner {
542
  display: grid;
543
  gap: 4px;
 
538
  font-weight: 750;
539
  }
540
 
541
+ .modelControl {
542
+ display: grid;
543
+ grid-template-columns: auto minmax(0, 1fr);
544
+ align-items: center;
545
+ gap: 8px;
546
+ padding: 8px 10px;
547
+ border-top: 1px solid rgb(255 255 255 / 10%);
548
+ background: rgb(255 255 255 / 5%);
549
+ }
550
+
551
+ .modelControl span {
552
+ color: #c7e6bd;
553
+ font-size: 10px;
554
+ font-weight: 750;
555
+ letter-spacing: 0.07em;
556
+ text-transform: uppercase;
557
+ }
558
+
559
+ .modelControl select {
560
+ min-width: 0;
561
+ height: 30px;
562
+ border: 1px solid rgb(255 255 255 / 18%);
563
+ border-radius: 6px;
564
+ background: rgb(18 28 23 / 88%);
565
+ color: #fffce9;
566
+ font-size: 11px;
567
+ }
568
+
569
+ .modelControl select:disabled {
570
+ opacity: 0.65;
571
+ }
572
+
573
  .directiveBanner {
574
  display: grid;
575
  gap: 4px;
frontend/src/types.ts CHANGED
@@ -32,6 +32,8 @@ export type EntitySnapshot = {
32
  role: string;
33
  country_id?: string | null;
34
  special_status?: string | null;
 
 
35
  position: Vec3;
36
  state: {
37
  intention: string;
@@ -216,3 +218,22 @@ export type ModelStatusSnapshot = {
216
  status: "unknown" | "checking" | "warmup" | "ready" | "error" | string;
217
  http_status: number | null;
218
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  role: string;
33
  country_id?: string | null;
34
  special_status?: string | null;
35
+ model_profile_id?: string | null;
36
+ connector_id?: string | null;
37
  position: Vec3;
38
  state: {
39
  intention: string;
 
218
  status: "unknown" | "checking" | "warmup" | "ready" | "error" | string;
219
  http_status: number | null;
220
  };
221
+
222
+ export type ModelProfileSnapshot = {
223
+ id: string;
224
+ label: string;
225
+ connector_id?: string | null;
226
+ model?: string | null;
227
+ type?: string;
228
+ };
229
+
230
+ export type AdminModelsSnapshot = {
231
+ profiles: ModelProfileSnapshot[];
232
+ npcs: {
233
+ id: string;
234
+ name: string;
235
+ country_id: string | null;
236
+ model_profile_id: string | null;
237
+ connector_id: string | null;
238
+ }[];
239
+ };
src/world_simulator/api/runtime.py CHANGED
@@ -360,7 +360,7 @@ class GameRuntime:
360
  raw = {}
361
  for item in raw.get("profiles", []) if isinstance(raw, dict) else []:
362
  if isinstance(item, dict) and isinstance(item.get("id"), str):
363
- profiles[item["id"]] = dict(item)
364
  return list(profiles.values())
365
 
366
  def _override_player_directives(self, world: WorldState, plan: TickPlan) -> TickPlan:
 
360
  raw = {}
361
  for item in raw.get("profiles", []) if isinstance(raw, dict) else []:
362
  if isinstance(item, dict) and isinstance(item.get("id"), str):
363
+ profiles[item["id"]] = {**profiles.get(item["id"], {}), **item}
364
  return list(profiles.values())
365
 
366
  def _override_player_directives(self, world: WorldState, plan: TickPlan) -> TickPlan:
src/world_simulator/api/server.py CHANGED
@@ -3,8 +3,9 @@ from __future__ import annotations
3
  import json
4
  from http import HTTPStatus
5
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 
6
  from typing import Any
7
- from urllib.parse import parse_qs, urlparse
8
 
9
  from world_simulator.api.runtime import (
10
  GameRuntime,
@@ -100,6 +101,11 @@ def build_handler(runtime: GameRuntime) -> type[BaseHTTPRequestHandler]:
100
  runtime.scene_state(warmup=_truthy_query_value(query, "warmup"))
101
  )
102
  return
 
 
 
 
 
103
 
104
  self._send_json(
105
  {"error": "not_found", "path": path},
@@ -107,13 +113,31 @@ def build_handler(runtime: GameRuntime) -> type[BaseHTTPRequestHandler]:
107
  )
108
 
109
  def do_POST(self) -> None:
110
- if self.path == "/tick":
 
 
 
111
  status, payload = runtime.tick()
112
  self._send_json(payload, status=status)
113
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
  self._send_json(
116
- {"error": "not_found", "path": self.path},
117
  status=HTTPStatus.NOT_FOUND,
118
  )
119
 
@@ -140,7 +164,17 @@ def build_handler(runtime: GameRuntime) -> type[BaseHTTPRequestHandler]:
140
  def _send_cors_headers(self) -> None:
141
  self.send_header("Access-Control-Allow-Origin", "*")
142
  self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
143
- self.send_header("Access-Control-Allow-Headers", "Content-Type")
 
 
 
 
 
 
 
 
 
 
144
 
145
  def _read_json_field(self, field: str) -> str:
146
  length_raw = self.headers.get("Content-Length")
 
3
  import json
4
  from http import HTTPStatus
5
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+ import os
7
  from typing import Any
8
+ from urllib.parse import parse_qs, unquote, urlparse
9
 
10
  from world_simulator.api.runtime import (
11
  GameRuntime,
 
101
  runtime.scene_state(warmup=_truthy_query_value(query, "warmup"))
102
  )
103
  return
104
+ if path == "/admin/models":
105
+ if not self._require_admin():
106
+ return
107
+ self._send_json(runtime.admin_models())
108
+ return
109
 
110
  self._send_json(
111
  {"error": "not_found", "path": path},
 
113
  )
114
 
115
  def do_POST(self) -> None:
116
+ parsed_path = urlparse(self.path)
117
+ path = parsed_path.path
118
+
119
+ if path == "/tick":
120
  status, payload = runtime.tick()
121
  self._send_json(payload, status=status)
122
  return
123
+ if path.startswith("/admin/npcs/") and path.endswith("/model"):
124
+ if not self._require_admin():
125
+ return
126
+ npc_id = unquote(path.removeprefix("/admin/npcs/").removesuffix("/model"))
127
+ try:
128
+ profile_id = self._read_json_field("profile_id")
129
+ except ValueError as exc:
130
+ self._send_json(
131
+ {"error": "bad_request", "message": str(exc)},
132
+ status=HTTPStatus.BAD_REQUEST,
133
+ )
134
+ return
135
+ status, payload = runtime.admin_set_npc_model(npc_id, profile_id)
136
+ self._send_json(payload, status=status)
137
+ return
138
 
139
  self._send_json(
140
+ {"error": "not_found", "path": path},
141
  status=HTTPStatus.NOT_FOUND,
142
  )
143
 
 
164
  def _send_cors_headers(self) -> None:
165
  self.send_header("Access-Control-Allow-Origin", "*")
166
  self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
167
+ self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Admin-Token")
168
+
169
+ def _require_admin(self) -> bool:
170
+ expected = os.getenv("ADMIN_TOKEN", "dev-admin-token")
171
+ if self.headers.get("X-Admin-Token") == expected:
172
+ return True
173
+ self._send_json(
174
+ {"error": "admin_token_required"},
175
+ status=HTTPStatus.FORBIDDEN,
176
+ )
177
+ return False
178
 
179
  def _read_json_field(self, field: str) -> str:
180
  length_raw = self.headers.get("Content-Length")
src/world_simulator/rendering/scene_contract.py CHANGED
@@ -51,6 +51,8 @@ def to_scene_snapshot(
51
  "role": npc.role,
52
  "country_id": npc.country_id,
53
  "special_status": npc.special_status,
 
 
54
  "position": {
55
  "x": npc.position.x,
56
  "y": npc.position.y,
 
51
  "role": npc.role,
52
  "country_id": npc.country_id,
53
  "special_status": npc.special_status,
54
+ "model_profile_id": npc.model_profile_id,
55
+ "connector_id": npc.connector_id,
56
  "position": {
57
  "x": npc.position.x,
58
  "y": npc.position.y,
src/world_simulator/simulation/connectors/openai_compatible.py CHANGED
@@ -47,6 +47,7 @@ from world_simulator.simulation.survival import (
47
  from world_simulator.simulation.survival import select_goal as select_survival_goal
48
 
49
  ChatCompleter = Callable[[dict[str, Any]], dict[str, Any]]
 
50
 
51
 
52
  @dataclass(frozen=True, slots=True)
@@ -315,7 +316,7 @@ class OpenAICompatibleWorldSimulator:
315
  )
316
 
317
  def propose_tick(self, world: WorldState, next_tick: int) -> TickPlan:
318
- fallback_plan = self._fallback.propose_tick(world, next_tick)
319
 
320
  try:
321
  llm_plan = self._call_model(world, next_tick)
@@ -363,6 +364,32 @@ class OpenAICompatibleWorldSimulator:
363
  ledger_entries=[*llm_plan.ledger_entries, *fallback_entries],
364
  )
365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  def _call_model(self, world: WorldState, next_tick: int) -> TickPlan:
367
  assert self._config.model is not None
368
 
@@ -380,7 +407,18 @@ class OpenAICompatibleWorldSimulator:
380
  directives.extend(result.directives)
381
  ledger_entries.extend(result.ledger_entries)
382
 
383
- missing_npcs = _missing_npcs(living_npcs, directives)
 
 
 
 
 
 
 
 
 
 
 
384
  if missing_npcs:
385
  print(
386
  f"Retrying {len(missing_npcs)} NPC(s) without LLM directives individually.",
@@ -425,6 +463,19 @@ class OpenAICompatibleWorldSimulator:
425
 
426
  return list(results)
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  def _call_model_for_npc(
429
  self,
430
  world: WorldState,
@@ -442,7 +493,7 @@ class OpenAICompatibleWorldSimulator:
442
  )
443
  try:
444
  started = time.perf_counter()
445
- response = self._chat_completer(request)
446
  latency_ms = round((time.perf_counter() - started) * 1000)
447
  except Exception as exc:
448
  print(
 
47
  from world_simulator.simulation.survival import select_goal as select_survival_goal
48
 
49
  ChatCompleter = Callable[[dict[str, Any]], dict[str, Any]]
50
+ MODEL_CALL_ATTEMPTS = 3
51
 
52
 
53
  @dataclass(frozen=True, slots=True)
 
316
  )
317
 
318
  def propose_tick(self, world: WorldState, next_tick: int) -> TickPlan:
319
+ fallback_plan = self._owned_fallback_plan(world, next_tick)
320
 
321
  try:
322
  llm_plan = self._call_model(world, next_tick)
 
364
  ledger_entries=[*llm_plan.ledger_entries, *fallback_entries],
365
  )
366
 
367
+ def _owned_fallback_plan(self, world: WorldState, next_tick: int) -> TickPlan:
368
+ fallback_plan = self._fallback.propose_tick(world, next_tick)
369
+ if self._connector_id_filter is _UNSET:
370
+ return fallback_plan
371
+
372
+ owned_npc_ids = {
373
+ npc.id
374
+ for npc in world.npcs
375
+ if is_alive(npc) and npc.connector_id == self._connector_id_filter
376
+ }
377
+ return TickPlan(
378
+ source=fallback_plan.source,
379
+ directives=[
380
+ directive
381
+ for directive in fallback_plan.directives
382
+ if directive.npc_id in owned_npc_ids
383
+ ],
384
+ overseer=fallback_plan.overseer,
385
+ ledger_entries=[
386
+ entry
387
+ for entry in fallback_plan.ledger_entries
388
+ if entry.get("npc_id") is None
389
+ or str(entry.get("npc_id")) in owned_npc_ids
390
+ ],
391
+ )
392
+
393
  def _call_model(self, world: WorldState, next_tick: int) -> TickPlan:
394
  assert self._config.model is not None
395
 
 
407
  directives.extend(result.directives)
408
  ledger_entries.extend(result.ledger_entries)
409
 
410
+ # Only retry NPCs the model never actually answered for (e.g. a batch
411
+ # call that omitted them). An NPC whose own call already failed/errored
412
+ # has an npc_response logged, so we must NOT call it again: re-hitting a
413
+ # dead/slow endpoint doubles every failure and freezes the tick.
414
+ attempted = {
415
+ str(entry.get("npc_id"))
416
+ for entry in ledger_entries
417
+ if entry.get("phase") == "npc_response"
418
+ }
419
+ missing_npcs = [
420
+ npc for npc in _missing_npcs(living_npcs, directives) if npc.id not in attempted
421
+ ]
422
  if missing_npcs:
423
  print(
424
  f"Retrying {len(missing_npcs)} NPC(s) without LLM directives individually.",
 
463
 
464
  return list(results)
465
 
466
+ def _complete_with_retries(self, request: dict[str, Any], npc_id: str) -> dict[str, Any]:
467
+ last_error: Exception | None = None
468
+ for attempt in range(1, MODEL_CALL_ATTEMPTS + 1):
469
+ try:
470
+ return self._chat_completer(request)
471
+ except Exception as exc:
472
+ last_error = exc
473
+ if attempt >= MODEL_CALL_ATTEMPTS:
474
+ break
475
+ time.sleep(0.5 * attempt)
476
+ assert last_error is not None
477
+ raise last_error
478
+
479
  def _call_model_for_npc(
480
  self,
481
  world: WorldState,
 
493
  )
494
  try:
495
  started = time.perf_counter()
496
+ response = self._complete_with_retries(request, npc.id)
497
  latency_ms = round((time.perf_counter() - started) * 1000)
498
  except Exception as exc:
499
  print(
tests/test_api_server.py CHANGED
@@ -13,7 +13,9 @@ from world_simulator.api.server import (
13
  _modal_health_urls,
14
  _ModalHealthTarget,
15
  _ModalHealthWarmer,
 
16
  )
 
17
  from world_simulator.config import (
18
  ConnectorConfig,
19
  GameConfig,
@@ -181,6 +183,62 @@ def test_modal_health_urls_use_only_modal_openai_connectors() -> None:
181
  ]
182
 
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  def test_forced_modal_warmup_does_not_duplicate_inflight_checks() -> None:
185
  opener = _BlockingOpener()
186
  warmer = _ModalHealthWarmer(
@@ -322,10 +380,16 @@ def _request_json(
322
  path: str,
323
  *,
324
  timeout: float,
 
 
325
  ) -> tuple[int, dict[str, Any]]:
326
  connection = HTTPConnection("127.0.0.1", port, timeout=timeout)
327
  try:
328
- connection.request(method, path)
 
 
 
 
329
  response = connection.getresponse()
330
  body = response.read().decode("utf-8")
331
  finally:
 
13
  _modal_health_urls,
14
  _ModalHealthTarget,
15
  _ModalHealthWarmer,
16
+ build_handler,
17
  )
18
+ from world_simulator.api.runtime import GameRuntime
19
  from world_simulator.config import (
20
  ConnectorConfig,
21
  GameConfig,
 
183
  ]
184
 
185
 
186
+ def test_http_server_admin_can_switch_npc_model(monkeypatch: pytest.MonkeyPatch) -> None:
187
+ monkeypatch.setenv("ADMIN_TOKEN", "test-admin")
188
+ config = _config(
189
+ npc_count=2,
190
+ connector=ConnectorConfig(
191
+ type="openai_compatible",
192
+ base_url="https://workspace--npc-serve.modal.run/v1",
193
+ model="npc-model",
194
+ ),
195
+ secondary_connectors={
196
+ "qwen": ConnectorConfig(
197
+ type="openai_compatible",
198
+ base_url="https://workspace--qwen-serve.modal.run/v1",
199
+ model="qwen-model",
200
+ )
201
+ },
202
+ )
203
+ world = create_world(config)
204
+ runtime = GameRuntime(world=world, simulator=_SlowSimulator(), config=config)
205
+ server = ThreadingHTTPServer(("127.0.0.1", 0), build_handler(runtime))
206
+ port = int(server.server_address[1])
207
+ server_thread = Thread(target=server.serve_forever, daemon=True)
208
+
209
+ server_thread.start()
210
+ try:
211
+ status, payload = _request_json(
212
+ port,
213
+ "GET",
214
+ "/admin/models",
215
+ timeout=1,
216
+ headers={"X-Admin-Token": "test-admin"},
217
+ )
218
+ assert status == 200
219
+ assert {profile["id"] for profile in payload["profiles"]} >= {"default", "qwen"}
220
+ qwen_profile = next(profile for profile in payload["profiles"] if profile["id"] == "qwen")
221
+ assert qwen_profile["model"] == "qwen-model"
222
+
223
+ status, payload = _request_json(
224
+ port,
225
+ "POST",
226
+ "/admin/npcs/npc-001/model",
227
+ timeout=1,
228
+ headers={"X-Admin-Token": "test-admin"},
229
+ body={"profile_id": "qwen"},
230
+ )
231
+
232
+ assert status == 200
233
+ assert payload["profile"]["id"] == "qwen"
234
+ assert world.npcs[0].model_profile_id == "qwen"
235
+ assert world.npcs[0].connector_id == "qwen"
236
+ finally:
237
+ server.shutdown()
238
+ server.server_close()
239
+ server_thread.join(timeout=1)
240
+
241
+
242
  def test_forced_modal_warmup_does_not_duplicate_inflight_checks() -> None:
243
  opener = _BlockingOpener()
244
  warmer = _ModalHealthWarmer(
 
380
  path: str,
381
  *,
382
  timeout: float,
383
+ headers: dict[str, str] | None = None,
384
+ body: dict[str, Any] | None = None,
385
  ) -> tuple[int, dict[str, Any]]:
386
  connection = HTTPConnection("127.0.0.1", port, timeout=timeout)
387
  try:
388
+ request_body = json.dumps(body).encode("utf-8") if body is not None else None
389
+ request_headers = dict(headers or {})
390
+ if request_body is not None:
391
+ request_headers.setdefault("Content-Type", "application/json")
392
+ connection.request(method, path, body=request_body, headers=request_headers)
393
  response = connection.getresponse()
394
  body = response.read().decode("utf-8")
395
  finally:
tests/test_country_systems.py CHANGED
@@ -116,6 +116,8 @@ def test_routing_preserves_ledger_entries_with_model_per_npc() -> None:
116
  }
117
  # Every living NPC of both countries produced a logged request.
118
  assert {n.id for n in world.living_npcs()} <= set(requests)
 
 
119
 
120
  nemo = requests["npc-001"]
121
  assert nemo["model"] == "nemotron-test"
 
116
  }
117
  # Every living NPC of both countries produced a logged request.
118
  assert {n.id for n in world.living_npcs()} <= set(requests)
119
+ assert "deterministic" not in plan.source
120
+ assert not [e for e in plan.ledger_entries if e.get("phase") == "npc_fallback"]
121
 
122
  nemo = requests["npc-001"]
123
  assert nemo["model"] == "nemotron-test"
tests/test_openai_connector.py CHANGED
@@ -518,6 +518,58 @@ def test_openai_connector_malformed_json_output_falls_back_without_breaking_tick
518
  assert world.last_tick_source == "openai_compatible"
519
 
520
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  def test_openai_connector_parses_optional_json_intent_and_confidence() -> None:
522
  world = create_world(_game_config(npc_count=1))
523
  response = {
 
518
  assert world.last_tick_source == "openai_compatible"
519
 
520
 
521
+ def test_openai_connector_retries_transient_model_call_failure() -> None:
522
+ calls = 0
523
+
524
+ def flaky_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
525
+ nonlocal calls
526
+ calls += 1
527
+ if calls == 1:
528
+ raise RuntimeError("temporary network failure")
529
+ payload = json.loads(request["messages"][1]["content"])
530
+ npc_id = payload["npc"]["id"]
531
+ return {
532
+ "choices": [
533
+ {
534
+ "message": {
535
+ "tool_calls": [
536
+ {
537
+ "type": "function",
538
+ "function": {
539
+ "name": "move",
540
+ "arguments": json.dumps(
541
+ {
542
+ "npc_id": npc_id,
543
+ "target_x": 5,
544
+ "target_z": 6,
545
+ }
546
+ ),
547
+ },
548
+ }
549
+ ]
550
+ }
551
+ }
552
+ ]
553
+ }
554
+
555
+ world = create_world(_game_config(npc_count=1))
556
+ simulator = OpenAICompatibleWorldSimulator(
557
+ ConnectorConfig(
558
+ type="openai_compatible",
559
+ base_url="http://llm.local/v1",
560
+ model="test-model",
561
+ ),
562
+ fallback=DeterministicWorldSimulator(),
563
+ chat_completer=flaky_chat_completer,
564
+ )
565
+
566
+ plan = simulator.propose_tick(world, 1)
567
+
568
+ assert calls == 2
569
+ assert len(plan.directives) == 1
570
+ assert not [entry for entry in plan.ledger_entries if entry.get("phase") == "npc_fallback"]
571
+
572
+
573
  def test_openai_connector_parses_optional_json_intent_and_confidence() -> None:
574
  world = create_world(_game_config(npc_count=1))
575
  response = {