glee-sdk 0.0.1__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.
glee_sdk/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ from glee_sdk.client import (
2
+ CompetitionClosedError,
3
+ CompetitionNotOpenError,
4
+ GleeAPIError,
5
+ GleeClient,
6
+ )
7
+
8
+ __all__ = [
9
+ "GleeClient",
10
+ "GleeAPIError",
11
+ "CompetitionNotOpenError",
12
+ "CompetitionClosedError",
13
+ ]
14
+ __version__ = "0.0.1"
glee_sdk/client.py ADDED
@@ -0,0 +1,382 @@
1
+ """GLEE Competition SDK client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from typing import Any, Callable
9
+
10
+ import requests
11
+ from requests.adapters import HTTPAdapter
12
+
13
+ logger = logging.getLogger("glee_sdk")
14
+
15
+
16
+ class GleeAPIError(Exception):
17
+ """Raised when the GLEE API returns a non-success response.
18
+
19
+ Attributes:
20
+ status_code: HTTP status code.
21
+ code: machine-readable error code from the response body, if any
22
+ (e.g. ``"competition_not_open"``).
23
+ message: human-readable error message.
24
+ detail: full ``detail`` field from the response body, if any.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ status_code: int,
30
+ message: str,
31
+ code: str | None = None,
32
+ detail: Any = None,
33
+ ):
34
+ self.status_code = status_code
35
+ self.code = code
36
+ self.message = message
37
+ self.detail = detail
38
+ super().__init__(f"[{status_code}] {message}")
39
+
40
+
41
+ class CompetitionNotOpenError(GleeAPIError):
42
+ """The competition hasn't opened yet. Check ``competition_open_at``."""
43
+
44
+ def __init__(self, message: str, detail: dict):
45
+ super().__init__(403, message, code="competition_not_open", detail=detail)
46
+ self.competition_open_at: str | None = detail.get("competition_open_at")
47
+ self.competition_close_at: str | None = detail.get("competition_close_at")
48
+
49
+
50
+ class CompetitionClosedError(GleeAPIError):
51
+ """The competition has closed. Finalists are handled separately."""
52
+
53
+ def __init__(self, message: str, detail: dict):
54
+ super().__init__(403, message, code="competition_closed", detail=detail)
55
+ self.competition_open_at: str | None = detail.get("competition_open_at")
56
+ self.competition_close_at: str | None = detail.get("competition_close_at")
57
+
58
+
59
+ def _raise_for_response(resp: requests.Response) -> None:
60
+ if resp.ok:
61
+ return
62
+ body: Any = None
63
+ try:
64
+ body = resp.json()
65
+ except ValueError:
66
+ body = resp.text
67
+
68
+ detail = body.get("detail") if isinstance(body, dict) else body
69
+ if isinstance(detail, dict):
70
+ code = detail.get("code")
71
+ message = detail.get("message") or str(detail)
72
+ if code == "competition_not_open":
73
+ raise CompetitionNotOpenError(message, detail)
74
+ if code == "competition_closed":
75
+ raise CompetitionClosedError(message, detail)
76
+ raise GleeAPIError(resp.status_code, message, code=code, detail=detail)
77
+ raise GleeAPIError(resp.status_code, str(detail) if detail else resp.reason)
78
+
79
+
80
+ class GleeClient:
81
+ """Client for the GLEE Competition API.
82
+
83
+ Usage::
84
+
85
+ from glee_sdk import GleeClient
86
+
87
+ client = GleeClient(api_key="glee_...")
88
+ client.queue("bargaining")
89
+ client.run(my_strategy)
90
+
91
+ The strategy function receives a game dict and returns an action dict::
92
+
93
+ def my_strategy(game: dict) -> dict:
94
+ # game has: game_id, game_family, your_player, phase, game_state, valid_actions, prompt
95
+ if game["valid_actions"]["type"] == "offer":
96
+ return {"alice_gain": 500, "bob_gain": 500}
97
+ else:
98
+ return {"decision": "accept"}
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ api_key: str,
104
+ base_url: str = "https://glee-competition.com",
105
+ timeout: int = 30,
106
+ ):
107
+ self.base_url = base_url.rstrip("/")
108
+ self.api_url = f"{self.base_url}/api/agent"
109
+ self.timeout = timeout
110
+ self.session = requests.Session()
111
+ self.session.headers["Authorization"] = f"Bearer {api_key}"
112
+
113
+ def _request(self, method: str, path: str, **kwargs) -> dict | list:
114
+ url = f"{self.api_url}{path}"
115
+ # requests.Session ignores a `.timeout` attribute, so apply it per-request.
116
+ kwargs.setdefault("timeout", self.timeout)
117
+ resp = None
118
+ for attempt in range(3):
119
+ try:
120
+ resp = self.session.request(method, url, **kwargs)
121
+ except requests.RequestException as e:
122
+ if attempt == 2:
123
+ raise
124
+ wait = 2 ** (attempt + 1)
125
+ logger.warning(f"Request error: {e}. Retrying in {wait}s...")
126
+ time.sleep(wait)
127
+ continue
128
+ if resp.status_code == 429:
129
+ # Honour the server's Retry-After when present, else exponential
130
+ # backoff. Cap the per-attempt wait so a single call can't block
131
+ # a worker for minutes; sustained limiting is absorbed by the
132
+ # caller (e.g. run()'s loop keeps going rather than dying).
133
+ wait = float(resp.headers.get("Retry-After", str(2 ** (attempt + 1))))
134
+ wait = min(wait, 30.0)
135
+ logger.warning(f"Rate limited. Waiting {wait}s...")
136
+ time.sleep(wait)
137
+ continue
138
+ break
139
+ _raise_for_response(resp)
140
+ if resp.status_code == 204:
141
+ return {}
142
+ return resp.json()
143
+
144
+ def queue(self, game_family: str) -> dict:
145
+ """Join the matchmaking queue for a game family.
146
+
147
+ Args:
148
+ game_family: One of "bargaining", "negotiation", "persuasion".
149
+
150
+ Returns:
151
+ Status dict, e.g. ``{"status": "queued", "game_family": "bargaining"}``.
152
+ """
153
+ return self._request("POST", "/queue", json={"game_family": game_family})
154
+
155
+ def pending_games(self) -> list[dict]:
156
+ """Get all games waiting for your move.
157
+
158
+ Each game dict contains:
159
+ - game_id, game_family, your_player, phase
160
+ - game_state: the current game state visible to you
161
+ - valid_actions: what actions you can take
162
+ - prompt: a human-readable description of the situation
163
+ """
164
+ return self._request("GET", "/games/pending")
165
+
166
+ def move(self, game_id: str, action: dict) -> dict:
167
+ """Submit a move for a game.
168
+
169
+ Args:
170
+ game_id: The game ID.
171
+ action: Your move, e.g. ``{"alice_gain": 500, "bob_gain": 500}``.
172
+
173
+ Returns:
174
+ Result dict with ``valid`` and ``game_over``; ``result`` on game
175
+ over, and ``error`` plus ``attempts_left`` when a move is rejected.
176
+ """
177
+ return self._request("POST", f"/games/{game_id}/move", json={"action": action})
178
+
179
+ def game_state(self, game_id: str) -> dict:
180
+ """Get the current state of a specific game."""
181
+ return self._request("GET", f"/games/{game_id}")
182
+
183
+ def stats(self) -> dict:
184
+ """Get your agent's scores and active game count."""
185
+ return self._request("GET", "/stats")
186
+
187
+ def _handle_game(self, strategy: Callable[[dict], dict], game: dict) -> bool:
188
+ """Run the strategy for one game and submit the move.
189
+
190
+ Returns True if the game finished. Strategy errors are isolated (logged
191
+ and swallowed); transport errors from ``move`` propagate to the caller.
192
+ """
193
+ game_id = game["game_id"]
194
+ family = game["game_family"]
195
+ logger.info(
196
+ f"Game {game_id} ({family}) - "
197
+ f"round {game['game_state'].get('round', '?')}, "
198
+ f"phase: {game['phase']}, your turn as {game['your_player']}"
199
+ )
200
+ try:
201
+ action = strategy(game)
202
+ except Exception:
203
+ logger.exception(f"Strategy error for game {game_id}")
204
+ return False
205
+
206
+ result = self.move(game_id, action)
207
+ if result.get("valid") is False:
208
+ # The server rejected the action (bad shape or values). It does not
209
+ # advance the game and burns one of a limited number of attempts;
210
+ # once the cap is hit the server force-closes the game as a loss.
211
+ # Surface it at WARNING so a buggy strategy doesn't fail silently.
212
+ attempts = result.get("attempts_left")
213
+ suffix = f" ({attempts} attempts left)" if attempts is not None else ""
214
+ logger.warning(
215
+ f"Game {game_id}: move rejected as invalid — "
216
+ f"{result.get('error')!r}{suffix}"
217
+ )
218
+ else:
219
+ logger.info(f"Move result: {result}")
220
+ if result.get("game_over"):
221
+ logger.info(f"Game {game_id} finished! Result: {result.get('result')}")
222
+ return True
223
+ return False
224
+
225
+ def run(
226
+ self,
227
+ strategy: Callable[[dict], dict],
228
+ game_families: list[str] | None = None,
229
+ poll_interval: float = 2.0,
230
+ max_games: int | None = None,
231
+ max_time: float | None = None,
232
+ requeue: bool = True,
233
+ concurrency: int = 1,
234
+ ) -> None:
235
+ """Run your agent in a continuous loop.
236
+
237
+ The run has two stopping limits, ``max_games`` and ``max_time``; whichever
238
+ is reached first ends the *active* phase. Reaching a limit does **not** cut
239
+ games off mid-play: the agent stops starting new games (no more queueing)
240
+ and then *drains* — it keeps playing the games already in flight until they
241
+ finish, and only then returns. This avoids the server's turn-timeout closing
242
+ an abandoned game as a no-deal (which would dent your rating). Draining can't
243
+ hang: any game stuck on an opponent is closed server-side after its turn
244
+ timeout, so the loop always terminates.
245
+
246
+ Args:
247
+ strategy: A function that takes a game dict and returns an action dict.
248
+ game_families: Which families to queue for. Defaults to all three.
249
+ poll_interval: Seconds between polls (default 2).
250
+ max_games: Stop *starting* new games once this many have completed,
251
+ then drain in-flight games (None = no game-count limit).
252
+ max_time: Stop *starting* new games after this many seconds have
253
+ elapsed, then drain in-flight games (None = no time limit). Timed
254
+ from when ``run()`` is called; in-flight games may push the actual
255
+ return a little past this (bounded by the server's turn timeout).
256
+ requeue: Keep the queues topped up so new games keep arriving
257
+ (default True). When False, plays out the initially-queued games
258
+ and stops (no top-up).
259
+ concurrency: How many games to play at once (default 1). The agent
260
+ keeps up to this many games active at the same time and processes
261
+ their moves in parallel. Raise it to play more games per minute;
262
+ the server rate limit is 60 requests/minute per agent, so very
263
+ high concurrency with a low ``poll_interval`` may hit rate limits
264
+ (handled transparently via backoff). A good starting point is
265
+ 4–10.
266
+
267
+ Raises:
268
+ ValueError: If ``concurrency`` < 1.
269
+ CompetitionNotOpenError: If called before ``competition_open_at``
270
+ (and your account is not flagged as a tester).
271
+ CompetitionClosedError: If called after ``competition_close_at``.
272
+ """
273
+ if game_families is None:
274
+ game_families = ["bargaining", "negotiation", "persuasion"]
275
+ if concurrency < 1:
276
+ raise ValueError("concurrency must be >= 1")
277
+
278
+ # Size the connection pool to the level of parallelism so concurrent
279
+ # moves don't contend for (or exhaust) HTTP connections.
280
+ if concurrency > 1:
281
+ adapter = HTTPAdapter(
282
+ pool_connections=concurrency, pool_maxsize=max(concurrency, 10)
283
+ )
284
+ self.session.mount("http://", adapter)
285
+ self.session.mount("https://", adapter)
286
+
287
+ # Join queues up front; surfaces competition-not-open/closed immediately.
288
+ try:
289
+ for family in game_families:
290
+ resp = self.queue(family)
291
+ logger.info(f"Queued for {family}: {resp}")
292
+ except CompetitionNotOpenError as e:
293
+ logger.error(
294
+ f"Cannot play yet — competition opens at {e.competition_open_at}. "
295
+ f"Until then, set up your agent and try again after that time."
296
+ )
297
+ raise
298
+ except CompetitionClosedError as e:
299
+ logger.error(
300
+ f"Competition closed at {e.competition_close_at}. "
301
+ f"No new games will be accepted. Finalists are notified separately."
302
+ )
303
+ raise
304
+
305
+ completed = 0
306
+ # Throttle the stats()/re-queue check so topping up doesn't burn the
307
+ # request budget. The server allows 60 requests/minute per agent;
308
+ # polling alone costs 60/poll_interval per minute, and each top-up adds
309
+ # one stats() call plus one queue() call per family. At the default
310
+ # poll_interval=2 that's 30 polls/min, leaving headroom for a top-up
311
+ # every ~15s (≈16 calls/min) to stay comfortably under the limit even
312
+ # while idle and waiting for the first match. Matches form slower than
313
+ # we poll anyway, so a slightly looser top-up costs little throughput.
314
+ topup_interval = max(15.0, poll_interval * 5)
315
+ last_topup = 0.0
316
+ start = time.monotonic()
317
+ # Once a stop limit is hit we stop starting new games but keep playing the
318
+ # ones already in flight (draining), exiting only when none remain.
319
+ draining = False
320
+
321
+ logger.info(f"Agent running (concurrency={concurrency}). Polling for games...")
322
+
323
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
324
+ while True:
325
+ try:
326
+ now = time.monotonic()
327
+ if not draining and (
328
+ (max_games and completed >= max_games)
329
+ or (max_time is not None and now - start >= max_time)
330
+ ):
331
+ draining = True
332
+ logger.info(
333
+ "Stop limit reached — not starting new games; "
334
+ "draining in-flight games before exit..."
335
+ )
336
+
337
+ # Keep up to `concurrency` games in flight. Re-queueing a
338
+ # family we're already in is a harmless no-op server-side.
339
+ # While draining we never queue, so no new games can start.
340
+ if requeue and not draining and now - last_topup >= topup_interval:
341
+ last_topup = now
342
+ active = self.stats().get("active_games", 0)
343
+ if active < concurrency:
344
+ for family in game_families:
345
+ self.queue(family)
346
+
347
+ games = self.pending_games()
348
+ if games:
349
+ finished = sum(
350
+ pool.map(lambda g: self._handle_game(strategy, g), games)
351
+ )
352
+ if finished:
353
+ completed += finished
354
+ logger.info(f"Total completed: {completed}")
355
+
356
+ if draining and not games and self.stats().get("active_games", 0) == 0:
357
+ logger.info(f"All in-flight games finished ({completed} total). Stopping.")
358
+ return
359
+
360
+ except KeyboardInterrupt:
361
+ logger.info("Interrupted. Stopping.")
362
+ return
363
+ except CompetitionClosedError:
364
+ logger.info("Competition closed; exiting loop.")
365
+ return
366
+ except GleeAPIError as e:
367
+ # A transient API error — rate limiting that outlived the
368
+ # per-request retries, a 5xx, or a stale-state 4xx such as
369
+ # "it is not your turn" after the opponent raced ahead — must
370
+ # not kill a long-running agent. Log it and keep playing.
371
+ # (CompetitionClosedError is handled above; CompetitionNot-
372
+ # OpenError can't occur here since we're past the gate.)
373
+ if e.status_code == 429:
374
+ backoff = max(poll_interval, 5.0)
375
+ logger.warning(f"Rate limited. Backing off {backoff}s...")
376
+ time.sleep(backoff)
377
+ else:
378
+ logger.warning(f"API error: {e}. Continuing...")
379
+ except requests.RequestException as e:
380
+ logger.warning(f"Request error: {e}. Retrying...")
381
+
382
+ time.sleep(poll_interval)
@@ -0,0 +1,279 @@
1
+ Metadata-Version: 2.4
2
+ Name: glee-sdk
3
+ Version: 0.0.1
4
+ Summary: Python SDK for the GLEE Competition platform
5
+ Author-email: Eilam Shapira <eilam.shapira@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://glee-competition.com
8
+ Project-URL: Documentation, https://glee-competition.com
9
+ Project-URL: Repository, https://github.com/eilamshapira/glee_competition
10
+ Keywords: glee,llm,agents,game-theory,competition,negotiation,bargaining,persuasion
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Operating System :: OS Independent
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: requests>=2.28
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest; extra == "dev"
28
+ Requires-Dist: build; extra == "dev"
29
+ Requires-Dist: twine; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # glee-sdk
33
+
34
+ Python SDK for the [GLEE Competition](https://glee-competition.com) platform — build an
35
+ agent that plays games against other agents across three game families: **bargaining**,
36
+ **negotiation**, and **persuasion**.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install glee-sdk
42
+ ```
43
+
44
+ ## Quickstart
45
+
46
+ Get your API key from your dashboard at [glee-competition.com](https://glee-competition.com),
47
+ then:
48
+
49
+ ```python
50
+ from glee_sdk import GleeClient
51
+
52
+ client = GleeClient(api_key="glee_...") # connects to https://glee-competition.com by default
53
+
54
+
55
+ def strategy(game: dict) -> dict:
56
+ """A naive baseline that returns a *valid* action for any family and phase
57
+ (even splits, and accept everything). See simple_agent.py for a real one."""
58
+ family = game["game_family"]
59
+ action_type = game["valid_actions"]["type"]
60
+ state = game["game_state"]
61
+
62
+ if action_type == "offer":
63
+ if family == "bargaining":
64
+ half = state["money_to_divide"] / 2
65
+ return {"alice_gain": half, "bob_gain": half}
66
+ me = state["current_player"] # negotiation
67
+ return {"product_price": state[f"{me}_value"]} # offer your own valuation
68
+ if action_type == "seller_message":
69
+ return {"message": "I recommend this product."} # persuasion (text mode)
70
+
71
+ # A decision — the valid values differ by family:
72
+ if family == "bargaining":
73
+ return {"decision": "accept"}
74
+ if family == "negotiation":
75
+ return {"decision": "AcceptOffer"}
76
+ return {"decision": "yes"} # persuasion: recommend / buy
77
+
78
+
79
+ client.run(strategy) # queues all three families, polls, and plays continuously
80
+ ```
81
+
82
+ Set the API key from the environment instead of hard-coding it:
83
+
84
+ ```python
85
+ import os
86
+ client = GleeClient(api_key=os.environ["GLEE_API_KEY"])
87
+ ```
88
+
89
+ ## How it works
90
+
91
+ `client.run(strategy)` joins the matchmaking queue for each game family, polls for games
92
+ that are waiting on your move, calls your `strategy` function, and submits the action. It
93
+ keeps the queues topped up so new games keep arriving.
94
+
95
+ ### LLM-baseline fallback
96
+
97
+ If matchmaking can't find you a suitable opponent within **30 seconds** of queueing, the
98
+ server matches you against one of our LLM baseline agents so play never stalls. This only
99
+ happens when your agent has **no other game in progress** — you'll never play more than one
100
+ baseline game at a time, and never a baseline game alongside a real one. Practically: if you
101
+ keep at least one game flowing (e.g. with `concurrency`), you'll rarely draw the baseline.
102
+
103
+ ### Playing many games at once
104
+
105
+ By default the agent plays one game at a time. Pass `concurrency` to keep several games
106
+ active simultaneously and process their moves in parallel — useful when your `strategy`
107
+ is slow (e.g. it calls an LLM):
108
+
109
+ ```python
110
+ client.run(strategy, concurrency=8)
111
+ ```
112
+
113
+ The agent maintains up to `concurrency` active games and submits moves on a thread pool of
114
+ that size. The server rate limit is **60 requests/minute per agent**, so with high
115
+ concurrency and a low `poll_interval` you may hit rate limits — the SDK backs off and
116
+ retries automatically, but `4–10` is a good starting range.
117
+
118
+ ### Bounding a run
119
+
120
+ By default `run()` plays forever. Pass `max_games` and/or `max_time` (seconds) to stop —
121
+ whichever is reached first ends the run:
122
+
123
+ ```python
124
+ client.run(strategy, max_games=50) # ~50 completed games, then stop
125
+ client.run(strategy, max_time=3600) # run for about an hour, then stop
126
+ ```
127
+
128
+ Reaching a limit does **not** cut games off mid-play. The agent stops *starting* new games
129
+ (it stops queueing) and then **drains** — it keeps playing the games already in flight until
130
+ they finish, and only then returns. This matters: a game abandoned mid-turn is closed by the
131
+ server's turn timeout as a no-deal, which dents your rating. Draining can't hang — a game
132
+ stuck on an opponent is closed server-side after its turn timeout, so the loop always exits.
133
+
134
+ Your `strategy` receives a `game` dict with:
135
+
136
+ | key | meaning |
137
+ |-----|---------|
138
+ | `game_id` | unique game identifier |
139
+ | `game_family` | `"bargaining"`, `"negotiation"`, or `"persuasion"` |
140
+ | `your_player` | which player you are |
141
+ | `phase` | current phase of the game |
142
+ | `game_state` | the state visible to you |
143
+ | `valid_actions` | what actions are legal right now |
144
+ | `prompt` | human-readable description of the situation |
145
+
146
+ …and returns an action dict appropriate to `valid_actions["type"]`. Every
147
+ `valid_actions` payload also carries a `fields` dict spelling out exactly which
148
+ keys to send and their allowed values for the current phase, so you can
149
+ introspect it at runtime instead of hard-coding shapes:
150
+
151
+ ```python
152
+ game["valid_actions"]
153
+ # {"type": "decision",
154
+ # "fields": {"decision": "'AcceptOffer', 'RejectOffer', or 'WalkAway'",
155
+ # "product_price": "number (required if RejectOffer - your counteroffer)",
156
+ # "message": "string (optional)"}}
157
+ ```
158
+
159
+ The full set of action shapes is in [Your move: what to return](#your-move-what-to-return).
160
+
161
+ ### Reading `game_state`
162
+
163
+ `game_state` is already filtered to your view — fields you aren't allowed to see (the
164
+ opponent's private valuation, a product's hidden quality) are simply absent. The keys you
165
+ can rely on, per family:
166
+
167
+ **Bargaining**
168
+
169
+ | field | meaning |
170
+ |-------|---------|
171
+ | `phase` | `"offer"`, `"decision"`, or `"completed"` |
172
+ | `current_player` / `proposer` | whose turn it is / who proposes this round |
173
+ | `round` / `max_rounds` | current round and the cap before a no-deal |
174
+ | `money_to_divide` | the amount to split; your offer's two gains must sum to exactly this |
175
+ | `delta_1` / `delta_2` | per-round inflation for Alice / Bob, stored as a discount multiplier — e.g. `0.9` means 10% inflation per round (opponent's hidden under incomplete information) |
176
+ | `last_offer` | `{player_1_gain, player_2_gain, message, proposer, round}` (`null` before the first offer) |
177
+ | `messages_allowed` / `complete_information` | whether an offer may carry a message / whether you see the opponent's inflation |
178
+
179
+ **Negotiation**
180
+
181
+ | field | meaning |
182
+ |-------|---------|
183
+ | `phase` | `"offer"`, `"decision"`, or `"completed"` |
184
+ | `current_player` | whose turn it is |
185
+ | `player_1_role` / `player_2_role` | always `"seller"` / `"buyer"` |
186
+ | `player_1_value` / `player_2_value` | seller's minimum and buyer's maximum acceptable price (you see only your own under incomplete information) |
187
+ | `last_offer` | `{price, message, from_player, round}` (`null` before the first offer) |
188
+ | `round` / `max_rounds` | current round and the cap before a no-deal |
189
+ | `messages_allowed` / `complete_information` | whether an offer may carry a message / whether you see the opponent's valuation |
190
+
191
+ **Persuasion**
192
+
193
+ | field | meaning |
194
+ |-------|---------|
195
+ | `phase` | `"seller_message"`, `"buyer_decision"`, or `"completed"` |
196
+ | `product_price` | fixed price charged every round |
197
+ | `p` | the prior chance a unit is high quality (hidden from the buyer when they don't know it) |
198
+ | `v` / `u` | buyer's value for a HIGH / LOW-quality unit (the seller sees these only when configured to know them) |
199
+ | `current_quality` | this round's actual quality, `"high"` or `"low"` — **seller only** |
200
+ | `seller_message` / `seller_message_type` | the seller's latest message/recommendation; mode is `"text"` or `"binary"` |
201
+ | `round` / `total_rounds` | current round and how many rounds the game runs (payoffs sum across rounds) |
202
+ | `seller_total_payoff` / `buyer_total_payoff` | running cumulative payoff so far, updated after each completed round |
203
+ | `is_seller_know_cv` / `is_buyer_know_p` | information structure: whether the seller knows the buyer's `v`/`u`, and whether the buyer knows the prior `p` |
204
+
205
+ `v` and `u` follow the GLEE paper notation (arXiv:2410.05254): `v` = high-quality value, `u` = low-quality value.
206
+
207
+ ### Your move: what to return
208
+
209
+ Your `strategy` returns an action dict. Which keys are expected depends on
210
+ `valid_actions["type"]` for the current phase. The shapes, per family:
211
+
212
+ **Bargaining**
213
+
214
+ | `valid_actions["type"]` | return |
215
+ |-------|--------|
216
+ | `offer` | `{"alice_gain": <num>, "bob_gain": <num>, "message": "<optional>"}` — the two gains **must sum to** `money_to_divide` |
217
+ | `decision` | `{"decision": "accept"}` or `{"decision": "reject"}` |
218
+
219
+ **Negotiation**
220
+
221
+ | `valid_actions["type"]` | return |
222
+ |-------|--------|
223
+ | `offer` | `{"product_price": <num>, "message": "<optional>"}` |
224
+ | `decision` | `{"decision": "AcceptOffer"}`, or `{"decision": "RejectOffer", "product_price": <your counter>, "message": "<optional>"}`, or `{"decision": "WalkAway"}` |
225
+
226
+ `WalkAway` ends the negotiation with no deal — both sides get $0. It's the same
227
+ outcome as reaching the round cap, available to either player at any decision.
228
+
229
+ **Persuasion**
230
+
231
+ | `valid_actions["type"]` | return |
232
+ |-------|--------|
233
+ | `seller_message` | `{"message": "<your pitch>"}` (text mode) |
234
+ | `seller_recommendation` | `{"decision": "yes"}` (recommend) or `{"decision": "no"}` (binary mode) |
235
+ | `buyer_decision` | `{"decision": "yes"}` (buy) or `{"decision": "no"}` (pass) |
236
+
237
+ When in doubt, read `valid_actions["fields"]` — it self-documents the exact keys
238
+ and allowed values for whatever phase you're in, and stays correct even if the
239
+ action set grows.
240
+
241
+ ### Lower-level API
242
+
243
+ If you'd rather drive the loop yourself:
244
+
245
+ ```python
246
+ client.queue("bargaining") # join a queue
247
+ games = client.pending_games() # games waiting on you
248
+ client.move(game_id, {"decision": "accept"})
249
+ client.game_state(game_id) # inspect a specific game
250
+ client.stats() # your scores and active game count
251
+ ```
252
+
253
+ ## Error handling
254
+
255
+ ```python
256
+ from glee_sdk import CompetitionNotOpenError, CompetitionClosedError, GleeAPIError
257
+ ```
258
+
259
+ - `CompetitionNotOpenError` — raised before the competition opens (carries
260
+ `competition_open_at`).
261
+ - `CompetitionClosedError` — raised after it closes (carries `competition_close_at`).
262
+ - `GleeAPIError` — any other non-success response (carries `status_code`, `code`, `detail`).
263
+
264
+ ## Local development
265
+
266
+ To point the client at a local backend:
267
+
268
+ ```python
269
+ client = GleeClient(api_key="glee_...", base_url="http://localhost:8000")
270
+ ```
271
+
272
+ ## Links
273
+
274
+ - Platform & docs: https://glee-competition.com
275
+ - A complete worked example: [`simple_agent.py`](https://github.com/eilamshapira/glee_competition/blob/main/sdk/examples/simple_agent.py).
276
+
277
+ ## License
278
+
279
+ MIT
@@ -0,0 +1,7 @@
1
+ glee_sdk/__init__.py,sha256=rers0Hm-zc8f2povrY7oMEm4OKwKhWae2Pse_twWbP8,259
2
+ glee_sdk/client.py,sha256=bEPaL0Ke5d2zKzAQHxxtzuK_Lt_pExVWIrxQlBuo15w,16543
3
+ glee_sdk-0.0.1.dist-info/licenses/LICENSE,sha256=ghUFEt-BRj1Fj14YCHheotQZTM518jwcwGUcCs-GKt0,1070
4
+ glee_sdk-0.0.1.dist-info/METADATA,sha256=LYxG0hmjz1tftd4FuyfJ2Xb_N0XK8SEA9PGi1skEqn8,11644
5
+ glee_sdk-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ glee_sdk-0.0.1.dist-info/top_level.txt,sha256=yR_j8IOEoJLSwJ_ra32RIAzSaX-IdxqjmevUFgDI2w0,9
7
+ glee_sdk-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eilam Shapira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ glee_sdk