spawnpoint-studio 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. spawnpoint/__init__.py +3 -0
  2. spawnpoint/_data/examples/coin_hopper.json +228 -0
  3. spawnpoint/_data/examples/neon_arena.json +251 -0
  4. spawnpoint/_data/examples/pixel_arena.json +242 -0
  5. spawnpoint/_data/examples/pixel_hopper.json +221 -0
  6. spawnpoint/_data/godot/addons/spawn_point/components/aim_component.gd +63 -0
  7. spawnpoint/_data/godot/addons/spawn_point/components/animator_component.gd +66 -0
  8. spawnpoint/_data/godot/addons/spawn_point/components/chase_ai_component.gd +107 -0
  9. spawnpoint/_data/godot/addons/spawn_point/components/collectible_component.gd +58 -0
  10. spawnpoint/_data/godot/addons/spawn_point/components/gate_component.gd +64 -0
  11. spawnpoint/_data/godot/addons/spawn_point/components/health_component.gd +83 -0
  12. spawnpoint/_data/godot/addons/spawn_point/components/hitbox_component.gd +50 -0
  13. spawnpoint/_data/godot/addons/spawn_point/components/hurtbox_component.gd +48 -0
  14. spawnpoint/_data/godot/addons/spawn_point/components/juice_component.gd +219 -0
  15. spawnpoint/_data/godot/addons/spawn_point/components/patrol_ai_component.gd +63 -0
  16. spawnpoint/_data/godot/addons/spawn_point/components/platformer_movement_component.gd +204 -0
  17. spawnpoint/_data/godot/addons/spawn_point/components/player_input_component.gd +77 -0
  18. spawnpoint/_data/godot/addons/spawn_point/components/projectile_component.gd +64 -0
  19. spawnpoint/_data/godot/addons/spawn_point/components/spawner_component.gd +58 -0
  20. spawnpoint/_data/godot/addons/spawn_point/components/stomp_component.gd +56 -0
  21. spawnpoint/_data/godot/addons/spawn_point/components/top_down_movement_component.gd +101 -0
  22. spawnpoint/_data/godot/addons/spawn_point/components/trigger_zone_component.gd +32 -0
  23. spawnpoint/_data/godot/addons/spawn_point/components/weapon_component.gd +83 -0
  24. spawnpoint/_data/godot/addons/spawn_point/manifest.json +1509 -0
  25. spawnpoint/_data/godot/addons/spawn_point/runtime/camera_rig.gd +71 -0
  26. spawnpoint/_data/godot/addons/spawn_point/runtime/cockpit.gd +62 -0
  27. spawnpoint/_data/godot/addons/spawn_point/runtime/entity_core.gd +98 -0
  28. spawnpoint/_data/godot/addons/spawn_point/runtime/fx.gd +112 -0
  29. spawnpoint/_data/godot/addons/spawn_point/runtime/game_state.gd +111 -0
  30. spawnpoint/_data/godot/addons/spawn_point/runtime/hud.gd +155 -0
  31. spawnpoint/_data/godot/addons/spawn_point/runtime/level.gd +19 -0
  32. spawnpoint/_data/godot/addons/spawn_point/runtime/sfx.gd +58 -0
  33. spawnpoint/_data/godot/addons/spawn_point/runtime/sp.gd +121 -0
  34. spawnpoint/_data/godot/addons/spawn_point/runtime/world.gd +130 -0
  35. spawnpoint/_data/godot/addons/spawn_point/shaders/flash.gdshader +9 -0
  36. spawnpoint/_data/godot/addons/spawn_point/shaders/floor_grid.gdshader +13 -0
  37. spawnpoint/_data/godot/addons/spawn_point/tools/build.gd +612 -0
  38. spawnpoint/_data/godot/addons/spawn_point/tools/build.tscn +6 -0
  39. spawnpoint/_data/godot/addons/spawn_point/tools/capture.gd +42 -0
  40. spawnpoint/_data/godot/addons/spawn_point/tools/capture.tscn +6 -0
  41. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.gd +91 -0
  42. spawnpoint/_data/godot/addons/spawn_point/tools/dump_manifest.tscn +6 -0
  43. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.gd +428 -0
  44. spawnpoint/_data/godot/addons/spawn_point/tools/playtest.tscn +6 -0
  45. spawnpoint/_data/godot/addons/spawn_point/tools/playtest_nav.gd +152 -0
  46. spawnpoint/_data/godot/addons/spawn_point/tools/record.gd +89 -0
  47. spawnpoint/_data/godot/addons/spawn_point/tools/record.tscn +6 -0
  48. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.gd +72 -0
  49. spawnpoint/_data/godot/addons/spawn_point/tools/smoke.tscn +6 -0
  50. spawnpoint/analyze.py +387 -0
  51. spawnpoint/build.py +146 -0
  52. spawnpoint/cli.py +290 -0
  53. spawnpoint/cockpit/__init__.py +0 -0
  54. spawnpoint/cockpit/server.py +473 -0
  55. spawnpoint/cockpit/static/app.css +111 -0
  56. spawnpoint/cockpit/static/app.js +327 -0
  57. spawnpoint/cockpit/static/index.html +79 -0
  58. spawnpoint/diagnostics.py +91 -0
  59. spawnpoint/expr.py +219 -0
  60. spawnpoint/gif.py +58 -0
  61. spawnpoint/godot.py +107 -0
  62. spawnpoint/graph.py +175 -0
  63. spawnpoint/level.py +157 -0
  64. spawnpoint/llm_server.py +77 -0
  65. spawnpoint/manifest.py +222 -0
  66. spawnpoint/playtest/__init__.py +104 -0
  67. spawnpoint/playtest/report.py +148 -0
  68. spawnpoint/playtest/runner.py +51 -0
  69. spawnpoint/playtest/tuner.py +151 -0
  70. spawnpoint/project.py +94 -0
  71. spawnpoint/py.typed +0 -0
  72. spawnpoint/rules.py +310 -0
  73. spawnpoint/sfx.py +352 -0
  74. spawnpoint/sprites/__init__.py +0 -0
  75. spawnpoint/sprites/ai.py +222 -0
  76. spawnpoint/sprites/contour.py +147 -0
  77. spawnpoint/sprites/generate.py +507 -0
  78. spawnpoint/sprites/importer.py +212 -0
  79. spawnpoint/sprites/resolve.py +139 -0
  80. spawnpoint/sprites/spriteset.py +159 -0
  81. spawnpoint/sprites/tiles.py +102 -0
  82. spawnpoint/studio/__init__.py +0 -0
  83. spawnpoint/studio/assemble.py +528 -0
  84. spawnpoint/studio/brief.py +197 -0
  85. spawnpoint/studio/creative.py +124 -0
  86. spawnpoint/studio/dataset.py +136 -0
  87. spawnpoint/studio/edits.py +206 -0
  88. spawnpoint/studio/levelgen.py +352 -0
  89. spawnpoint/studio/llm.py +145 -0
  90. spawnpoint/studio/orchestrator.py +341 -0
  91. spawnpoint/studio/prompts.py +122 -0
  92. spawnpoint/studio/web.py +123 -0
  93. spawnpoint/studio/webcheck.py +210 -0
  94. spawnpoint/studio_cli.py +232 -0
  95. spawnpoint_studio-0.1.0.dist-info/METADATA +360 -0
  96. spawnpoint_studio-0.1.0.dist-info/RECORD +118 -0
  97. spawnpoint_studio-0.1.0.dist-info/WHEEL +5 -0
  98. spawnpoint_studio-0.1.0.dist-info/entry_points.txt +2 -0
  99. spawnpoint_studio-0.1.0.dist-info/licenses/LICENSE +21 -0
  100. spawnpoint_studio-0.1.0.dist-info/top_level.txt +2 -0
  101. tests/conftest.py +49 -0
  102. tests/test_ai_sprites.py +122 -0
  103. tests/test_analyze.py +116 -0
  104. tests/test_cockpit.py +148 -0
  105. tests/test_e2e.py +33 -0
  106. tests/test_expr.py +59 -0
  107. tests/test_gif.py +19 -0
  108. tests/test_godot_library.py +17 -0
  109. tests/test_graph.py +44 -0
  110. tests/test_level.py +64 -0
  111. tests/test_live_llm.py +38 -0
  112. tests/test_manifest.py +54 -0
  113. tests/test_playtest.py +129 -0
  114. tests/test_rules.py +85 -0
  115. tests/test_sfx.py +30 -0
  116. tests/test_sprites.py +261 -0
  117. tests/test_studio.py +335 -0
  118. tests/test_web.py +41 -0
@@ -0,0 +1,352 @@
1
+ """Deterministic level generators.
2
+
3
+ Platformer levels are stitched from hand-designed chunks; top-down levels are bordered arenas whose
4
+ obstacles never disconnect the floor. Generators only produce geometry and *slots*; the assembler
5
+ decides what goes into each slot.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import random
11
+ from collections import deque
12
+ from dataclasses import dataclass, field
13
+
14
+ Cell = tuple[int, int]
15
+
16
+ PLATFORMER_HEIGHT = 14
17
+ STAND_ROW = 11 # row the player / walkers stand on (ground top is row 12)
18
+
19
+
20
+ @dataclass
21
+ class Layout:
22
+ width: int
23
+ height: int
24
+ tiles: dict[Cell, str] = field(default_factory=dict) # solid | oneway
25
+ player: Cell = (0, 0)
26
+ exit: Cell | None = None
27
+ door: Cell | None = None
28
+ ground_slots: list[Cell] = field(default_factory=list) # walkers, turrets...
29
+ air_slots: list[Cell] = field(default_factory=list) # pickups
30
+ hazard_slots: list[Cell] = field(default_factory=list) # spikes
31
+ portal_slots: list[Cell] = field(default_factory=list) # spawners (top-down)
32
+ free_cells: list[Cell] = field(default_factory=list) # walkable, unoccupied (top-down)
33
+ chunks: list[str] = field(default_factory=list) # platformer chunk names, in order
34
+
35
+ def is_free(self, c: Cell) -> bool:
36
+ return c not in self.tiles
37
+
38
+
39
+ # --- platformer chunks ------------------------------------------------------------------------
40
+ # Rows are absolute rows 4..13 (the top 4 rows are always empty). Markers:
41
+ # '#' solid '=' one-way 'g' ground enemy slot 'c' pickup slot 'h' hazard slot '.' empty
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Chunk:
46
+ name: str
47
+ rows: tuple[str, ...]
48
+ needs_up: int # tiles of jump height required
49
+ pit: bool = False
50
+ min_difficulty: int = 1
51
+ needs_across: int = 0 # tiles of horizontal jump distance required
52
+
53
+
54
+ CHUNKS: tuple[Chunk, ...] = (
55
+ Chunk("flat", (
56
+ "..........",
57
+ "..........",
58
+ "..........",
59
+ "..........",
60
+ "..........",
61
+ "...c..c...",
62
+ "..........",
63
+ "..g....g..",
64
+ "##########",
65
+ "##########"), 0),
66
+ Chunk("low_platforms", (
67
+ "............",
68
+ "............",
69
+ "............",
70
+ "............",
71
+ "....cc......",
72
+ "............",
73
+ "...====.....",
74
+ ".g.......g..",
75
+ "############",
76
+ "############"), 2),
77
+ Chunk("stairs", (
78
+ "..............",
79
+ "..............",
80
+ "..........cc..",
81
+ "..............",
82
+ ".........====.",
83
+ "..............",
84
+ "..====........",
85
+ "g......g......",
86
+ "##############",
87
+ "##############"), 2),
88
+ Chunk("block_step", (
89
+ "............",
90
+ "............",
91
+ "............",
92
+ "............",
93
+ "....c.......",
94
+ "............",
95
+ "....###.....",
96
+ ".g..###...g.",
97
+ "############",
98
+ "############"), 2),
99
+ Chunk("tower", (
100
+ "............",
101
+ "............",
102
+ "............",
103
+ ".....ccc....",
104
+ "............",
105
+ ".....###....",
106
+ ".....###....",
107
+ ".g...###..g.",
108
+ "############",
109
+ "############"), 3, min_difficulty=2),
110
+ Chunk("high_platform", (
111
+ "............",
112
+ "............",
113
+ "....cccc....",
114
+ "............",
115
+ "...======...",
116
+ "............",
117
+ "............",
118
+ "..g......g..",
119
+ "############",
120
+ "############"), 4, min_difficulty=2),
121
+ Chunk("spike_run", (
122
+ "............",
123
+ "............",
124
+ "............",
125
+ "............",
126
+ "............",
127
+ "....c..c....",
128
+ "............",
129
+ "..hh....hh..",
130
+ "############",
131
+ "############"), 2),
132
+ Chunk("low_ceiling", (
133
+ "............",
134
+ "............",
135
+ "............",
136
+ "............",
137
+ "..########..",
138
+ "............",
139
+ "...c.c.c....",
140
+ "..g.....g...",
141
+ "############",
142
+ "############"), 0),
143
+ Chunk("gap", (
144
+ "..........",
145
+ "..........",
146
+ "..........",
147
+ "..........",
148
+ "..........",
149
+ "....cc....",
150
+ "..........",
151
+ "..g.......",
152
+ "####..####",
153
+ "####..####"), 0, pit=True, needs_across=3),
154
+ Chunk("bridge_gap", (
155
+ "..............",
156
+ "..............",
157
+ "..............",
158
+ "..............",
159
+ "......cc......",
160
+ "..............",
161
+ ".....====.....",
162
+ "..g........g..",
163
+ "####......####",
164
+ "####......####"), 2, pit=True, min_difficulty=2, needs_across=3),
165
+ )
166
+
167
+ START = (
168
+ "......",
169
+ "......",
170
+ "......",
171
+ "......",
172
+ "......",
173
+ "......",
174
+ "......",
175
+ "..P...",
176
+ "######",
177
+ "######",
178
+ )
179
+
180
+ LENGTH_CHUNKS = {"short": 4, "medium": 7, "long": 11}
181
+
182
+
183
+ def _fits(c: Chunk, jump_up: int, jump_across: int, pits: bool) -> bool:
184
+ return c.needs_up <= jump_up and c.needs_across <= jump_across and (pits or not c.pit)
185
+
186
+
187
+ def usable_chunks(jump_up: int, jump_across: int, pits: bool, difficulty: int) -> list[Chunk]:
188
+ return [c for c in CHUNKS if _fits(c, jump_up, jump_across, pits) and c.min_difficulty <= difficulty]
189
+
190
+
191
+ CHUNK_BY_NAME = {c.name: c for c in CHUNKS}
192
+
193
+
194
+ def plan_chunks(seed: int, length: str, jump_up: int, jump_across: int, pits: bool, difficulty: int) -> list[str]:
195
+ rng = random.Random(f"chunks:{seed}")
196
+ pool = usable_chunks(jump_up, jump_across, pits, difficulty)
197
+ picks: list[Chunk] = []
198
+ for _ in range(LENGTH_CHUNKS[length]):
199
+ options = [c for c in pool if not picks or c.name != picks[-1].name] or pool
200
+ # Harder levels lean toward chunks that need more skill.
201
+ weights = [1.0 + (c.needs_up + (2 if c.pit else 0)) * (difficulty - 1) * 0.25 for c in options]
202
+ picks.append(rng.choices(options, weights)[0])
203
+ return [c.name for c in picks]
204
+
205
+
206
+ def chunks_fit(names: list[str], length: str, jump_up: int, jump_across: int, pits: bool) -> bool:
207
+ """Whether a previously planned chunk sequence still works for changed player abilities."""
208
+ if len(names) != LENGTH_CHUNKS[length] or any(n not in CHUNK_BY_NAME for n in names):
209
+ return False
210
+ return all(_fits(CHUNK_BY_NAME[n], jump_up, jump_across, pits) for n in names)
211
+
212
+
213
+ def platformer(seed: int, length: str, jump_up: int, jump_across: int, pits: bool, difficulty: int, door: bool,
214
+ chunks: list[str] | None = None) -> Layout:
215
+ names = chunks if chunks is not None else plan_chunks(seed, length, jump_up, jump_across, pits, difficulty)
216
+ picks = [CHUNK_BY_NAME[n] for n in names]
217
+
218
+ lay = Layout(0, PLATFORMER_HEIGHT)
219
+ x0 = _stamp(lay, START, 0)
220
+ for chunk in picks:
221
+ x0 = _stamp(lay, chunk.rows, x0)
222
+ x0 = _end_chunk(lay, x0, door)
223
+ lay.width = x0
224
+ lay.chunks = list(names)
225
+ return lay
226
+
227
+
228
+ def floor_below(lay: Layout, c: Cell) -> int | None:
229
+ """Height in tiles of cell `c` above the floor the player would stand on beneath it."""
230
+ x, y = c
231
+ for yy in range(y + 1, lay.height):
232
+ if (x, yy) in lay.tiles:
233
+ return yy - 1 - y
234
+ return None
235
+
236
+
237
+ def _stamp(lay: Layout, rows: tuple[str, ...], x0: int) -> int:
238
+ top = PLATFORMER_HEIGHT - len(rows)
239
+ for dy, row in enumerate(rows):
240
+ y = top + dy
241
+ for dx, ch in enumerate(row):
242
+ c = (x0 + dx, y)
243
+ if ch == "#":
244
+ lay.tiles[c] = "solid"
245
+ elif ch == "=":
246
+ lay.tiles[c] = "oneway"
247
+ elif ch == "g":
248
+ lay.ground_slots.append(c)
249
+ elif ch == "c":
250
+ lay.air_slots.append(c)
251
+ elif ch == "h":
252
+ lay.hazard_slots.append(c)
253
+ elif ch == "P":
254
+ lay.player = c
255
+ return x0 + len(rows[0])
256
+
257
+
258
+ def _end_chunk(lay: Layout, x0: int, door: bool) -> int:
259
+ width = 9
260
+ for x in range(x0, x0 + width):
261
+ lay.tiles[(x, 12)] = lay.tiles[(x, 13)] = "solid"
262
+ if door:
263
+ # A wall from the ceiling down to the door, so the exit can only be reached through it.
264
+ for y in range(0, 8):
265
+ lay.tiles[(x0 + 2, y)] = "solid"
266
+ lay.door = (x0 + 2, STAND_ROW)
267
+ lay.exit = (x0 + 6, STAND_ROW)
268
+ return x0 + width
269
+
270
+
271
+ def extra_ground_cells(lay: Layout, min_x: int) -> list[Cell]:
272
+ """Standing cells on solid ground that no slot uses (for overflow enemies / pickups)."""
273
+ used = set(lay.ground_slots) | set(lay.air_slots) | set(lay.hazard_slots)
274
+ blocked = {lay.player, lay.exit, lay.door}
275
+ out = []
276
+ for x in range(min_x, lay.width):
277
+ c = (x, STAND_ROW)
278
+ if lay.tiles.get((x, STAND_ROW + 1)) == "solid" and lay.is_free(c) and c not in used and c not in blocked \
279
+ and lay.is_free((x, STAND_ROW - 1)):
280
+ out.append(c)
281
+ if lay.door:
282
+ out = [c for c in out if c[0] < lay.door[0] - 1]
283
+ return out
284
+
285
+
286
+ # --- top-down arena ---------------------------------------------------------------------------
287
+
288
+ ARENA_SIZE = {"short": (28, 18), "medium": (36, 24), "long": (46, 30)}
289
+ OBSTACLES = ((2, 2), (4, 1), (1, 4), (3, 3), (2, 1), (1, 2))
290
+
291
+
292
+ def arena(seed: int, length: str, difficulty: int) -> Layout:
293
+ w, h = ARENA_SIZE[length]
294
+ lay = Layout(w, h)
295
+ for x in range(w):
296
+ lay.tiles[(x, 0)] = lay.tiles[(x, h - 1)] = "solid"
297
+ for y in range(h):
298
+ lay.tiles[(0, y)] = lay.tiles[(w - 1, y)] = "solid"
299
+ lay.player = (w // 2, h // 2)
300
+
301
+ rng = random.Random(f"arena:{seed}")
302
+ target = (w * h) // (34 - difficulty * 2)
303
+ placed = attempts = 0
304
+ while placed * 4 < target and attempts < 400:
305
+ attempts += 1
306
+ ow, oh = rng.choice(OBSTACLES)
307
+ x, y = rng.randint(2, w - 3 - ow), rng.randint(2, h - 3 - oh)
308
+ cells = [(x + i, y + j) for i in range(ow) for j in range(oh)]
309
+ if any(abs(cx - lay.player[0]) <= 3 and abs(cy - lay.player[1]) <= 3 for cx, cy in cells):
310
+ continue
311
+ if any(c in lay.tiles for c in cells):
312
+ continue
313
+ for c in cells:
314
+ lay.tiles[c] = "solid"
315
+ if not _connected(lay):
316
+ for c in cells:
317
+ del lay.tiles[c]
318
+ continue
319
+ placed += 1
320
+
321
+ dist = _distances(lay, lay.player)
322
+ lay.free_cells = sorted(c for c in dist if c != lay.player)
323
+ lay.exit = max(dist, key=lambda c: (dist[c], c))
324
+ corners = [(2, 2), (w - 3, h - 3), (w - 3, 2), (2, h - 3)]
325
+ lay.portal_slots = [_nearest_free(lay, dist, c) for c in corners]
326
+ return lay
327
+
328
+
329
+ def _neighbors(c: Cell):
330
+ x, y = c
331
+ return ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1))
332
+
333
+
334
+ def _distances(lay: Layout, start: Cell) -> dict[Cell, int]:
335
+ dist = {start: 0}
336
+ q = deque([start])
337
+ while q:
338
+ c = q.popleft()
339
+ for n in _neighbors(c):
340
+ if n not in dist and lay.is_free(n) and 0 <= n[0] < lay.width and 0 <= n[1] < lay.height:
341
+ dist[n] = dist[c] + 1
342
+ q.append(n)
343
+ return dist
344
+
345
+
346
+ def _connected(lay: Layout) -> bool:
347
+ free = sum(1 for x in range(lay.width) for y in range(lay.height) if lay.is_free((x, y)))
348
+ return len(_distances(lay, lay.player)) == free
349
+
350
+
351
+ def _nearest_free(lay: Layout, dist: dict[Cell, int], c: Cell) -> Cell:
352
+ return min(dist, key=lambda d: (abs(d[0] - c[0]) + abs(d[1] - c[1]), d))
@@ -0,0 +1,145 @@
1
+ """Minimal OpenAI-compatible chat client with structured (JSON-schema) output.
2
+
3
+ Works with vLLM, OpenAI, and any server implementing /v1/chat/completions with
4
+ `response_format: {"type": "json_schema"}`. No third-party dependencies.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, Protocol
16
+
17
+ DEFAULT_BASE_URL = "http://localhost:8000/v1"
18
+
19
+
20
+ class LLMError(RuntimeError):
21
+ pass
22
+
23
+
24
+ @dataclass
25
+ class Usage:
26
+ calls: int = 0
27
+ prompt_tokens: int = 0
28
+ completion_tokens: int = 0
29
+ seconds: float = 0.0
30
+
31
+ def add(self, other: "Usage") -> None:
32
+ self.calls += other.calls
33
+ self.prompt_tokens += other.prompt_tokens
34
+ self.completion_tokens += other.completion_tokens
35
+ self.seconds += other.seconds
36
+
37
+ def __str__(self) -> str:
38
+ return (f"{self.calls} call(s), {self.prompt_tokens} prompt + {self.completion_tokens} completion tokens, "
39
+ f"{self.seconds:.1f}s")
40
+
41
+
42
+ class ChatModel(Protocol):
43
+ name: str
44
+ usage: Usage
45
+
46
+ def chat_json(self, messages: list[dict], schema: dict, schema_name: str, *,
47
+ temperature: float = 0.3, max_tokens: int = 1500) -> dict: ...
48
+
49
+
50
+ @dataclass
51
+ class OpenAICompatible:
52
+ base_url: str = field(default_factory=lambda: os.environ.get("SPAWNPOINT_LLM_BASE_URL", DEFAULT_BASE_URL))
53
+ model: str | None = field(default_factory=lambda: os.environ.get("SPAWNPOINT_LLM_MODEL"))
54
+ api_key: str | None = field(default_factory=lambda: os.environ.get("SPAWNPOINT_LLM_API_KEY"))
55
+ timeout: float = 180.0
56
+ usage: Usage = field(default_factory=Usage)
57
+
58
+ @property
59
+ def name(self) -> str:
60
+ return self.model or "?"
61
+
62
+ def _request(self, path: str, payload: dict | None = None) -> dict:
63
+ url = self.base_url.rstrip("/") + path
64
+ data = json.dumps(payload).encode() if payload is not None else None
65
+ req = urllib.request.Request(url, data=data, method="POST" if data else "GET")
66
+ req.add_header("Content-Type", "application/json")
67
+ if self.api_key:
68
+ req.add_header("Authorization", f"Bearer {self.api_key}")
69
+ try:
70
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
71
+ return json.loads(resp.read())
72
+ except urllib.error.HTTPError as e:
73
+ body = e.read().decode(errors="replace")[:500]
74
+ raise LLMError(f"LLM server returned {e.code} for {path}: {body}") from e
75
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
76
+ raise LLMError(
77
+ f"cannot reach the LLM at {self.base_url} ({e}). Start it (e.g. `docker start spawnpoint-llm`) "
78
+ "or set SPAWNPOINT_LLM_BASE_URL.") from e
79
+
80
+ def resolve_model(self) -> str:
81
+ if not self.model:
82
+ models = self._request("/models").get("data", [])
83
+ if not models:
84
+ raise LLMError(f"the LLM server at {self.base_url} lists no models")
85
+ # A fine-tuned adapter (vLLM lists it with a `parent`) beats the base model it sits on.
86
+ adapters = [m for m in models if m.get("parent")]
87
+ self.model = (adapters or models)[0]["id"]
88
+ return self.model
89
+
90
+ def chat_json(self, messages: list[dict], schema: dict, schema_name: str, *,
91
+ temperature: float = 0.3, max_tokens: int = 1500) -> dict:
92
+ payload: dict[str, Any] = {
93
+ "model": self.resolve_model(),
94
+ "messages": messages,
95
+ "temperature": temperature,
96
+ "max_tokens": max_tokens,
97
+ "response_format": {"type": "json_schema",
98
+ "json_schema": {"name": schema_name, "schema": schema, "strict": True}},
99
+ }
100
+ t0 = time.perf_counter()
101
+ resp = self._request("/chat/completions", payload)
102
+ elapsed = time.perf_counter() - t0
103
+ u = resp.get("usage") or {}
104
+ self.usage.add(Usage(1, u.get("prompt_tokens", 0), u.get("completion_tokens", 0), elapsed))
105
+ try:
106
+ content = resp["choices"][0]["message"]["content"]
107
+ except (KeyError, IndexError, TypeError) as e:
108
+ raise LLMError(f"unexpected LLM response: {str(resp)[:300]}") from e
109
+ return parse_json(content)
110
+
111
+
112
+ def parse_json(content: str) -> dict:
113
+ """Parse model output; tolerate code fences or text around a JSON object."""
114
+ text = (content or "").strip()
115
+ if text.startswith("```"):
116
+ text = text.strip("`")
117
+ text = text[text.find("{"):]
118
+ try:
119
+ return json.loads(text)
120
+ except json.JSONDecodeError:
121
+ start, end = text.find("{"), text.rfind("}")
122
+ if start >= 0 and end > start:
123
+ try:
124
+ return json.loads(text[start:end + 1])
125
+ except json.JSONDecodeError:
126
+ pass
127
+ raise LLMError(f"LLM did not return valid JSON: {text[:300]}")
128
+
129
+
130
+ @dataclass
131
+ class ScriptedModel:
132
+ """Test double: returns canned responses in order and records the prompts it saw."""
133
+
134
+ responses: list[dict]
135
+ name: str = "scripted"
136
+ usage: Usage = field(default_factory=Usage)
137
+ seen: list[list[dict]] = field(default_factory=list)
138
+
139
+ def chat_json(self, messages: list[dict], schema: dict, schema_name: str, *,
140
+ temperature: float = 0.3, max_tokens: int = 1500) -> dict:
141
+ self.seen.append(messages)
142
+ self.usage.add(Usage(1, 0, 0, 0.0))
143
+ if not self.responses:
144
+ raise LLMError("scripted model ran out of responses")
145
+ return self.responses.pop(0)