playmaker-cli 0.11.0__tar.gz → 0.12.0__tar.gz

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 (51) hide show
  1. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/CHANGELOG.md +8 -0
  2. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/PKG-INFO +5 -1
  3. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/README.md +4 -0
  4. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/pyproject.toml +1 -1
  5. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/quotas.md +3 -0
  6. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/cli.py +2 -0
  7. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/quotas.py +302 -0
  8. playmaker_cli-0.12.0/tests/test_quotas_kimi.py +220 -0
  9. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/.gitignore +0 -0
  10. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/LICENSE +0 -0
  11. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/SKILL.md +0 -0
  12. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/agent-gotchas.md +0 -0
  13. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/commands.md +0 -0
  14. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/lanes.md +0 -0
  15. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/prompt-templates.md +0 -0
  16. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/review-board.md +0 -0
  17. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/scripts/review-board.sh +0 -0
  18. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/__init__.py +0 -0
  19. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/__main__.py +0 -0
  20. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/__init__.py +0 -0
  21. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/agy.py +0 -0
  22. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/base.py +0 -0
  23. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/claude.py +0 -0
  24. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/codex.py +0 -0
  25. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/gemini.py +0 -0
  26. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/kimi.py +0 -0
  27. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/opencode.py +0 -0
  28. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/config.py +0 -0
  29. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/notify.py +0 -0
  30. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/registry.py +0 -0
  31. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/state.py +0 -0
  32. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/watcher.py +0 -0
  33. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/__init__.py +0 -0
  34. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/fixtures/kimi_wire.jsonl +0 -0
  35. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_agy.py +0 -0
  36. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_batch.py +0 -0
  37. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_binary.py +0 -0
  38. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_claude.py +0 -0
  39. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_codex.py +0 -0
  40. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_kimi.py +0 -0
  41. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_no_changes.py +0 -0
  42. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_opencode.py +0 -0
  43. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_permissions.py +0 -0
  44. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_quotas_antigravity.py +0 -0
  45. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_quotas_claude.py +0 -0
  46. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_quotas_codex.py +0 -0
  47. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_quotas_freshness.py +0 -0
  48. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_quotas_zai.py +0 -0
  49. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_registry.py +0 -0
  50. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_skill.py +0 -0
  51. {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_state.py +0 -0
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.12.0] - 2026-09-04
9
+
10
+ ### Added
11
+
12
+ - **Kimi Code usage in `playmaker quotas`.** The table reads Kimi Code's
13
+ managed OAuth usage endpoint and shows its rolling 5-hour Session and weekly
14
+ buckets beside the other provider pools.
15
+
8
16
  ## [0.11.0] - 2026-09-04
9
17
 
10
18
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: playmaker-cli
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Playing-coach CLI for orchestrating Claude Code, Codex, Antigravity, Kimi Code and opencode sub-agents in parallel.
5
5
  Project-URL: Homepage, https://github.com/vladsafedev/playmaker
6
6
  Project-URL: Repository, https://github.com/vladsafedev/playmaker
@@ -446,6 +446,10 @@ whole Z.ai block. Routing a subtask is choosing which of them to spend.
446
446
  lane pointed at a local model spends nothing here. Shows *unsupported* rather
447
447
  than an error when no Z.ai credential exists. `MCP tools` is the monthly
448
448
  web-search/reader pool, not inference.
449
+ - **Kimi Code** — managed usage API; OAuth token from
450
+ `~/.kimi-code/credentials/kimi-code-env-*.json` (`$KIMI_CODE_HOME` overrides
451
+ the root). Its 5-hour `Session` and weekly rows are separate percentage
452
+ buckets; no credential reads as *unsupported* rather than a failed probe.
449
453
 
450
454
  Reading these at *model* granularity is the point: they are the load-balancing
451
455
  input the coach skill uses to route each subtask.
@@ -419,6 +419,10 @@ whole Z.ai block. Routing a subtask is choosing which of them to spend.
419
419
  lane pointed at a local model spends nothing here. Shows *unsupported* rather
420
420
  than an error when no Z.ai credential exists. `MCP tools` is the monthly
421
421
  web-search/reader pool, not inference.
422
+ - **Kimi Code** — managed usage API; OAuth token from
423
+ `~/.kimi-code/credentials/kimi-code-env-*.json` (`$KIMI_CODE_HOME` overrides
424
+ the root). Its 5-hour `Session` and weekly rows are separate percentage
425
+ buckets; no credential reads as *unsupported* rather than a failed probe.
422
426
 
423
427
  Reading these at *model* granularity is the point: they are the load-balancing
424
428
  input the coach skill uses to route each subtask.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "playmaker-cli"
3
- version = "0.11.0"
3
+ version = "0.12.0"
4
4
  description = "Playing-coach CLI for orchestrating Claude Code, Codex, Antigravity, Kimi Code and opencode sub-agents in parallel."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -24,6 +24,9 @@ several tiers with independent buckets, and the whole point of pulling quotas is
24
24
  - **Codex:** the main block is the primary Codex window; `Codex — Spark` is a separate block with
25
25
  its own `Session` / `Weekly` rows for the Spark model, the junior Codex lane — route Spark work
26
26
  off that block, not off the main one.
27
+ - **Kimi Code:** `Session` is its rolling 5-hour bucket and `Weekly` is the subscription's weekly
28
+ percentage bucket. They are independent of Claude, Codex and the opencode-backed plans, so Kimi
29
+ is a useful alternate lane when either of those two rows still has headroom.
27
30
  - **opencode:** the quota belongs to the *plan behind the provider*, not to the CLI — it appears
28
31
  under that provider (e.g. a GLM coding plan's session and weekly credit windows) and reads
29
32
  unsupported without a credential. A dispatch pointed at a **local** model spends nothing and never
@@ -1004,11 +1004,13 @@ def _render_provider(name: str, info: dict) -> None:
1004
1004
  "agy": "green",
1005
1005
  "gemini": "cyan",
1006
1006
  "zai": "yellow",
1007
+ "kimi": "cyan",
1007
1008
  "ollama": "bright_cyan",
1008
1009
  }.get(name, "white")
1009
1010
  display = {
1010
1011
  "agy": "Antigravity (agy)",
1011
1012
  "zai": "Z.ai (GLM, via opencode)",
1013
+ "kimi": "Kimi Code",
1012
1014
  "ollama": "Ollama (local, via opencode)",
1013
1015
  }.get(name, name.capitalize())
1014
1016
  title = f"[bold {label_color}]{display}[/bold {label_color}]"
@@ -9,6 +9,8 @@ All providers go through OAuth Bearer tokens; no WebKit, no PTY.
9
9
  - Gemini (retired locally): same creds -> cloudcode-pa.googleapis.com
10
10
  - Z.ai (GLM, dispatched via opencode): API key in opencode's auth.json ->
11
11
  api.z.ai/api/monitor/usage/quota/limit
12
+ - Kimi Code: OAuth credential in ~/.kimi-code/credentials ->
13
+ api.kimi.ai/coding/v1/usages
12
14
  - Ollama (local, also via opencode): no quota to fetch — localhost:11434
13
15
  /api/tags is an availability probe reporting unmetered capacity
14
16
  """
@@ -22,7 +24,9 @@ import re
22
24
  import shutil
23
25
  import ssl
24
26
  import subprocess
27
+ import tempfile
25
28
  import time
29
+ import tomllib
26
30
  import urllib.error
27
31
  import urllib.parse
28
32
  import urllib.request
@@ -1161,6 +1165,303 @@ def zai_probe() -> dict:
1161
1165
  }
1162
1166
 
1163
1167
 
1168
+ # ---- Kimi Code --------------------------------------------------------------
1169
+
1170
+
1171
+ _KIMI_CODE_DEFAULT_BASE_URL = "https://api.kimi.ai/coding/v1"
1172
+ _KIMI_CODE_DEFAULT_OAUTH_HOST = "https://auth.kimi.ai"
1173
+
1174
+ # Public installed-app client id from kimi-code 0.41.0's OAuth bundle. The
1175
+ # bundle refreshes at <oauth_host>/api/oauth/token with this form body.
1176
+ _KIMI_CODE_OAUTH_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"
1177
+
1178
+
1179
+ def _kimi_code_home() -> Path:
1180
+ configured = os.environ.get("KIMI_CODE_HOME")
1181
+ return Path(configured).expanduser() if configured else Path("~/.kimi-code").expanduser()
1182
+
1183
+
1184
+ def _kimi_load_config(home: Path) -> dict:
1185
+ try:
1186
+ config = tomllib.loads((home / "config.toml").read_text(encoding="utf-8"))
1187
+ except (OSError, tomllib.TOMLDecodeError):
1188
+ return {}
1189
+ return config if isinstance(config, dict) else {}
1190
+
1191
+
1192
+ def _kimi_provider_config(config: dict) -> dict:
1193
+ providers = config.get("providers")
1194
+ if not isinstance(providers, dict):
1195
+ return {}
1196
+ provider = providers.get("managed:kimi-code")
1197
+ return provider if isinstance(provider, dict) else {}
1198
+
1199
+
1200
+ def _kimi_runtime_urls(config: dict) -> tuple[str, str]:
1201
+ """Return the configured managed API and OAuth hosts, defaulting global."""
1202
+ provider = _kimi_provider_config(config)
1203
+ base_url = provider.get("base_url") or provider.get("baseUrl")
1204
+ oauth = provider.get("oauth")
1205
+ oauth = oauth if isinstance(oauth, dict) else {}
1206
+ oauth_host = oauth.get("oauth_host") or oauth.get("oauthHost")
1207
+ resolved_base_url = (
1208
+ str(base_url).rstrip("/")
1209
+ if isinstance(base_url, str) and base_url
1210
+ else _KIMI_CODE_DEFAULT_BASE_URL
1211
+ )
1212
+ resolved_oauth_host = (
1213
+ str(oauth_host).rstrip("/")
1214
+ if isinstance(oauth_host, str) and oauth_host
1215
+ else _KIMI_CODE_DEFAULT_OAUTH_HOST
1216
+ )
1217
+ return (
1218
+ resolved_base_url,
1219
+ resolved_oauth_host,
1220
+ )
1221
+
1222
+
1223
+ def _kimi_credential_paths(home: Path, config: dict) -> list[Path]:
1224
+ """Prefer this environment's configured OAuth file, then other CLI files."""
1225
+ credentials_dir = home / "credentials"
1226
+ provider = _kimi_provider_config(config)
1227
+ oauth = provider.get("oauth")
1228
+ oauth = oauth if isinstance(oauth, dict) else {}
1229
+ oauth_key = oauth.get("key")
1230
+ configured: list[Path] = []
1231
+ if isinstance(oauth_key, str):
1232
+ name = Path(oauth_key).name
1233
+ if name.startswith("kimi-code-env-"):
1234
+ configured.append(credentials_dir / f"{name}.json")
1235
+
1236
+ try:
1237
+ discovered = sorted(
1238
+ credentials_dir.glob("kimi-code-env-*.json"),
1239
+ key=lambda path: path.stat().st_mtime,
1240
+ reverse=True,
1241
+ )
1242
+ except OSError:
1243
+ discovered = []
1244
+ return configured + [path for path in discovered if path not in configured]
1245
+
1246
+
1247
+ def _kimi_load_credentials() -> tuple[dict, Path, dict] | None:
1248
+ home = _kimi_code_home()
1249
+ config = _kimi_load_config(home)
1250
+ for path in _kimi_credential_paths(home, config):
1251
+ try:
1252
+ credentials = json.loads(path.read_text(encoding="utf-8"))
1253
+ except (OSError, json.JSONDecodeError):
1254
+ continue
1255
+ if isinstance(credentials, dict) and isinstance(credentials.get("access_token"), str):
1256
+ return credentials, path, config
1257
+ return None
1258
+
1259
+
1260
+ def _kimi_save_credentials(path: Path, credentials: dict) -> None:
1261
+ tmp_path: Path | None = None
1262
+ try:
1263
+ with tempfile.NamedTemporaryFile(
1264
+ mode="w",
1265
+ encoding="utf-8",
1266
+ dir=path.parent,
1267
+ prefix=f".{path.name}.",
1268
+ suffix=".tmp",
1269
+ delete=False,
1270
+ ) as tmp:
1271
+ tmp_path = Path(tmp.name)
1272
+ tmp_path.chmod(0o600)
1273
+ tmp.write(json.dumps(credentials, indent=2) + "\n")
1274
+ tmp_path.replace(path)
1275
+ finally:
1276
+ if tmp_path is not None:
1277
+ try:
1278
+ tmp_path.unlink()
1279
+ except FileNotFoundError:
1280
+ pass
1281
+
1282
+
1283
+ def _kimi_refresh(credentials: dict, path: Path, oauth_host: str) -> dict:
1284
+ """Refresh a Kimi CLI OAuth token and persist it to its existing file."""
1285
+ refresh_token = credentials.get("refresh_token")
1286
+ if not isinstance(refresh_token, str) or not refresh_token:
1287
+ raise RuntimeError("no refresh_token in Kimi credential")
1288
+ body = urllib.parse.urlencode(
1289
+ {
1290
+ "client_id": _KIMI_CODE_OAUTH_CLIENT_ID,
1291
+ "grant_type": "refresh_token",
1292
+ "refresh_token": refresh_token,
1293
+ }
1294
+ )
1295
+ response = _http_json(
1296
+ f"{oauth_host.rstrip('/')}/api/oauth/token",
1297
+ method="POST",
1298
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
1299
+ body=body,
1300
+ )
1301
+ access_token = response.get("access_token")
1302
+ expires_in = response.get("expires_in")
1303
+ if not isinstance(access_token, str) or not access_token:
1304
+ raise RuntimeError("Kimi token refresh response missing access_token")
1305
+ if not isinstance(expires_in, (int, float)) or expires_in <= 0:
1306
+ raise RuntimeError("Kimi token refresh response missing expires_in")
1307
+
1308
+ refreshed = dict(credentials)
1309
+ refreshed["access_token"] = access_token
1310
+ if isinstance(response.get("refresh_token"), str) and response["refresh_token"]:
1311
+ refreshed["refresh_token"] = response["refresh_token"]
1312
+ refreshed["expires_in"] = int(expires_in)
1313
+ refreshed["expires_at"] = int(time.time()) + int(expires_in)
1314
+ if isinstance(response.get("token_type"), str):
1315
+ refreshed["token_type"] = response["token_type"]
1316
+ if isinstance(response.get("scope"), str):
1317
+ refreshed["scope"] = response["scope"]
1318
+ _kimi_save_credentials(path, refreshed)
1319
+ return refreshed
1320
+
1321
+
1322
+ def _humanize_kimi_tier(level: object) -> str | None:
1323
+ if not isinstance(level, str) or not level:
1324
+ return None
1325
+ suffix = level.removeprefix("LEVEL_")
1326
+ return suffix.replace("_", " ").title()
1327
+
1328
+
1329
+ def _kimi_window(
1330
+ name: str, detail: dict, *, window_seconds: float, include_forecast: bool
1331
+ ) -> dict | None:
1332
+ limit = detail.get("limit")
1333
+ remaining = detail.get("remaining")
1334
+ used = detail.get("used")
1335
+ if not isinstance(limit, (int, float, str)):
1336
+ return None
1337
+ try:
1338
+ limit_value = float(limit)
1339
+ if isinstance(remaining, (int, float, str)):
1340
+ remaining_value = float(remaining)
1341
+ elif isinstance(used, (int, float, str)):
1342
+ # The live API omits `remaining` at 0%, while retaining `used`.
1343
+ remaining_value = limit_value - float(used)
1344
+ else:
1345
+ return None
1346
+ except ValueError:
1347
+ return None
1348
+ if limit_value <= 0:
1349
+ return None
1350
+
1351
+ reset_at = detail.get("resetTime")
1352
+ reset_iso = reset_at if isinstance(reset_at, str) else None
1353
+ forecast: str | None = None
1354
+ if include_forecast and reset_iso:
1355
+ try:
1356
+ reset_dt = datetime.fromisoformat(reset_iso.replace("Z", "+00:00"))
1357
+ elapsed = max(0.0, window_seconds - (reset_dt.timestamp() - time.time()))
1358
+ used_pct = max(0.0, min(100.0, 100 - (remaining_value / limit_value * 100)))
1359
+ forecast = _forecast_label(used_pct, window_seconds, elapsed)
1360
+ except ValueError:
1361
+ pass
1362
+ return {
1363
+ "name": name,
1364
+ "pct_left": max(0, min(100, int(round(remaining_value / limit_value * 100)))),
1365
+ "reset_at_iso": reset_iso,
1366
+ "reset_relative": _format_relative(reset_iso),
1367
+ "forecast": forecast,
1368
+ "reserve_pct": None,
1369
+ }
1370
+
1371
+
1372
+ def kimi_probe() -> dict:
1373
+ """Kimi Code OAuth usage, as reported by its managed `/usages` endpoint.
1374
+
1375
+ `usage` is the weekly percentage bucket. `limits[]` holds rolling windows;
1376
+ the 300-minute detail is Kimi Code's session quota. The stored access token
1377
+ is intentionally never refreshed by making a chat request: when it is
1378
+ expired, this uses the CLI's documented OAuth refresh request instead.
1379
+ """
1380
+ loaded = _kimi_load_credentials()
1381
+ if loaded is None:
1382
+ return {
1383
+ "status": "unsupported",
1384
+ "reason": "no Kimi Code credential — run `kimi login --region global`",
1385
+ }
1386
+ credentials, credentials_path, config = loaded
1387
+ access_token = credentials.get("access_token")
1388
+ expires_at = credentials.get("expires_at")
1389
+ base_url, oauth_host = _kimi_runtime_urls(config)
1390
+ if not isinstance(access_token, str) or not access_token:
1391
+ return {
1392
+ "status": "unsupported",
1393
+ "reason": "Kimi token expired — run any `kimi` command to refresh it",
1394
+ }
1395
+ if not isinstance(expires_at, (int, float)) or expires_at <= time.time():
1396
+ if (
1397
+ not isinstance(credentials.get("refresh_token"), str)
1398
+ or not credentials["refresh_token"]
1399
+ ):
1400
+ return {
1401
+ "status": "unsupported",
1402
+ "reason": "Kimi token expired — run any `kimi` command to refresh it",
1403
+ }
1404
+ credentials = _kimi_refresh(credentials, credentials_path, oauth_host)
1405
+ access_token = credentials["access_token"]
1406
+
1407
+ try:
1408
+ response = _http_json(
1409
+ f"{base_url.rstrip('/')}/usages",
1410
+ headers={"Authorization": f"Bearer {access_token}"},
1411
+ )
1412
+ except RuntimeError as exc:
1413
+ if "HTTP 401" not in str(exc) or not isinstance(credentials.get("refresh_token"), str):
1414
+ raise
1415
+ credentials = _kimi_refresh(credentials, credentials_path, oauth_host)
1416
+ response = _http_json(
1417
+ f"{base_url.rstrip('/')}/usages",
1418
+ headers={"Authorization": f"Bearer {credentials['access_token']}"},
1419
+ )
1420
+
1421
+ usage = response.get("usage")
1422
+ limits = response.get("limits")
1423
+ user = response.get("user")
1424
+ if not isinstance(usage, dict) or not isinstance(limits, list) or not isinstance(user, dict):
1425
+ return {
1426
+ "status": "unsupported",
1427
+ "reason": f"unrecognised quota payload: {str(response)[:200]}",
1428
+ }
1429
+ membership = user.get("membership")
1430
+
1431
+ session: dict | None = None
1432
+ for limit in limits:
1433
+ if not isinstance(limit, dict):
1434
+ continue
1435
+ window = limit.get("window")
1436
+ detail = limit.get("detail")
1437
+ if (
1438
+ isinstance(window, dict)
1439
+ and window.get("duration") == 300
1440
+ and window.get("timeUnit") == "TIME_UNIT_MINUTE"
1441
+ and isinstance(detail, dict)
1442
+ ):
1443
+ # Session windows churn too quickly for a useful pace forecast,
1444
+ # matching the Claude and Z.ai rows in this table.
1445
+ session = _kimi_window(
1446
+ "Session", detail, window_seconds=5 * 3600, include_forecast=False
1447
+ )
1448
+ break
1449
+ weekly = _kimi_window("Weekly", usage, window_seconds=7 * 86400, include_forecast=True)
1450
+ if session is None or weekly is None:
1451
+ return {
1452
+ "status": "unsupported",
1453
+ "reason": f"unrecognised quota payload: {str(response)[:200]}",
1454
+ }
1455
+
1456
+ level = membership.get("level") if isinstance(membership, dict) else None
1457
+ return {
1458
+ "status": "ok",
1459
+ "account_email": None,
1460
+ "tier": _humanize_kimi_tier(level),
1461
+ "windows": [session, weekly],
1462
+ }
1463
+
1464
+
1164
1465
  # ---- Ollama (local, unmetered) ----------------------------------------------
1165
1466
 
1166
1467
  # Ollama's own API, not the OpenAI-compatible /v1 shim that opencode dispatches
@@ -1285,6 +1586,7 @@ PROBES = {
1285
1586
  "claude": claude_probe,
1286
1587
  "agy": antigravity_probe,
1287
1588
  "zai": zai_probe,
1589
+ "kimi": kimi_probe,
1288
1590
  "ollama": ollama_probe,
1289
1591
  }
1290
1592
 
@@ -0,0 +1,220 @@
1
+ """Kimi Code quota probe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import stat
7
+ import sys
8
+ import time
9
+ import urllib.parse
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
15
+
16
+ import playmaker.quotas as quotas
17
+
18
+ # Captured verbatim from https://api.kimi.ai/coding/v1/usages on a live
19
+ # Moderato (LEVEL_BASIC) account, 2026-09-04. `usage` is the weekly percent
20
+ # bucket; each `limits[]` entry is a rolling window, here five hours.
21
+ _PAYLOAD = json.loads(
22
+ '{"user":{"userId":"d9gg8obmrb73tddocqb0","region":"REGION_OVERSEA","membership":{"level":"LEVEL_BASIC"},"businessId":""},"usage":{"limit":"100","used":"1","remaining":"99","resetTime":"2026-09-11T14:50:20.391966Z"},"limits":[{"window":{"duration":300,"timeUnit":"TIME_UNIT_MINUTE"},"detail":{"limit":"100","used":"3","remaining":"97","resetTime":"2026-09-04T19:50:20.391966Z"}}],"parallel":{"limit":"10"},"totalQuota":{},"authentication":{"method":"METHOD_ACCESS_TOKEN","scope":"FEATURE_CODING"},"subType":"TYPE_PURCHASE","domain":"DOMAIN_NEXUS","version":"GOODS_VERSION_V1"}'
23
+ )
24
+
25
+
26
+ @pytest.fixture
27
+ def credential(monkeypatch, tmp_path) -> Path:
28
+ """Point the probe at the Kimi CLI's global managed-provider credential."""
29
+ home = tmp_path / "kimi-code"
30
+ monkeypatch.setenv("KIMI_CODE_HOME", str(home))
31
+ credentials = home / "credentials" / "kimi-code-env-deadbeef.json"
32
+ credentials.parent.mkdir(parents=True)
33
+ credentials.write_text(
34
+ json.dumps(
35
+ {
36
+ "access_token": "access-test",
37
+ "refresh_token": "refresh-test",
38
+ "expires_at": int(time.time()) + 900,
39
+ "expires_in": 900,
40
+ "token_type": "Bearer",
41
+ "scope": "kimi-code",
42
+ }
43
+ ),
44
+ encoding="utf-8",
45
+ )
46
+ credentials.chmod(0o600)
47
+ (home / "config.toml").write_text(
48
+ """[providers.\"managed:kimi-code\"]
49
+ base_url = "https://api.kimi.ai/coding/v1"
50
+
51
+ [providers.\"managed:kimi-code\".oauth]
52
+ key = "oauth/kimi-code-env-deadbeef"
53
+ oauth_host = "https://auth.kimi.ai"
54
+ """,
55
+ encoding="utf-8",
56
+ )
57
+ return credentials
58
+
59
+
60
+ def _respond(monkeypatch, payload: dict) -> dict:
61
+ seen: dict = {}
62
+
63
+ def fake(url, **kwargs):
64
+ seen["url"] = url
65
+ seen["headers"] = kwargs.get("headers") or {}
66
+ return payload
67
+
68
+ monkeypatch.setattr(quotas, "_http_json", fake)
69
+ return seen
70
+
71
+
72
+ def test_usage_is_normalized_to_session_and_weekly(monkeypatch, credential) -> None:
73
+ _respond(monkeypatch, _PAYLOAD)
74
+
75
+ result = quotas.kimi_probe()
76
+
77
+ assert result["status"] == "ok"
78
+ assert result["tier"] == "Basic"
79
+ assert [(window["name"], window["pct_left"]) for window in result["windows"]] == [
80
+ ("Session", 97),
81
+ ("Weekly", 99),
82
+ ]
83
+
84
+
85
+ def test_usage_endpoint_and_bearer_token_follow_the_managed_config(monkeypatch, credential) -> None:
86
+ seen = _respond(monkeypatch, _PAYLOAD)
87
+
88
+ quotas.kimi_probe()
89
+
90
+ assert seen["url"] == "https://api.kimi.ai/coding/v1/usages"
91
+ assert seen["headers"]["Authorization"] == "Bearer access-test"
92
+
93
+
94
+ def test_session_keeps_its_reset_time_but_has_no_pace_forecast(monkeypatch, credential) -> None:
95
+ _respond(monkeypatch, _PAYLOAD)
96
+
97
+ session, weekly = quotas.kimi_probe()["windows"]
98
+
99
+ assert session["reset_at_iso"] == "2026-09-04T19:50:20.391966Z"
100
+ assert session["forecast"] is None
101
+ assert weekly["reset_at_iso"] == "2026-09-11T14:50:20.391966Z"
102
+
103
+
104
+ def test_an_exhausted_window_without_remaining_still_renders(monkeypatch, credential) -> None:
105
+ payload = json.loads(json.dumps(_PAYLOAD))
106
+ detail = payload["limits"][0]["detail"]
107
+ detail["used"] = "100"
108
+ detail.pop("remaining")
109
+ _respond(monkeypatch, payload)
110
+
111
+ session = quotas.kimi_probe()["windows"][0]
112
+
113
+ assert session["pct_left"] == 0
114
+
115
+
116
+ def test_expired_token_uses_the_cli_refresh_request_and_persists_it(
117
+ monkeypatch, credential
118
+ ) -> None:
119
+ expired = json.loads(credential.read_text(encoding="utf-8"))
120
+ expired["expires_at"] = int(time.time()) - 1
121
+ credential.write_text(json.dumps(expired), encoding="utf-8")
122
+ seen: list[tuple[str, dict]] = []
123
+
124
+ def fake(url, **kwargs):
125
+ seen.append((url, kwargs))
126
+ if url == "https://auth.kimi.ai/api/oauth/token":
127
+ return {
128
+ "access_token": "access-refreshed",
129
+ "refresh_token": "refresh-refreshed",
130
+ "expires_in": 900,
131
+ "token_type": "Bearer",
132
+ "scope": "kimi-code",
133
+ }
134
+ return _PAYLOAD
135
+
136
+ monkeypatch.setattr(quotas, "_http_json", fake)
137
+
138
+ result = quotas.kimi_probe()
139
+
140
+ assert result["status"] == "ok"
141
+ refresh_url, refresh_kwargs = seen[0]
142
+ assert refresh_url == "https://auth.kimi.ai/api/oauth/token"
143
+ assert refresh_kwargs["method"] == "POST"
144
+ assert refresh_kwargs["headers"]["Content-Type"] == "application/x-www-form-urlencoded"
145
+ assert urllib.parse.parse_qs(refresh_kwargs["body"]) == {
146
+ "client_id": ["17e5f671-d194-4dfb-9706-5516cb48c098"],
147
+ "grant_type": ["refresh_token"],
148
+ "refresh_token": ["refresh-test"],
149
+ }
150
+ saved = json.loads(credential.read_text(encoding="utf-8"))
151
+ assert saved["access_token"] == "access-refreshed"
152
+ assert saved["refresh_token"] == "refresh-refreshed"
153
+ assert stat.S_IMODE(credential.stat().st_mode) == 0o600
154
+ assert seen[1][1]["headers"]["Authorization"] == "Bearer access-refreshed"
155
+
156
+
157
+ def test_credential_write_uses_a_mode_600_sibling_temp_file(monkeypatch, credential) -> None:
158
+ seen: dict = {}
159
+ original_replace = Path.replace
160
+
161
+ def record_replace(source: Path, target: Path) -> Path:
162
+ seen["source"] = source
163
+ seen["target"] = target
164
+ seen["mode_at_replace"] = stat.S_IMODE(source.stat().st_mode)
165
+ return original_replace(source, target)
166
+
167
+ monkeypatch.setattr(Path, "replace", record_replace)
168
+
169
+ quotas._kimi_save_credentials(credential, {"access_token": "saved"})
170
+
171
+ assert seen["source"].parent == credential.parent
172
+ assert seen["source"] != credential
173
+ assert seen["source"].name.startswith(f".{credential.name}.")
174
+ assert seen["target"] == credential
175
+ assert seen["mode_at_replace"] == 0o600
176
+ assert stat.S_IMODE(credential.stat().st_mode) == 0o600
177
+ assert json.loads(credential.read_text(encoding="utf-8")) == {"access_token": "saved"}
178
+
179
+
180
+ def test_expired_token_without_a_refresh_token_is_unsupported(monkeypatch, credential) -> None:
181
+ expired = json.loads(credential.read_text(encoding="utf-8"))
182
+ expired.pop("refresh_token")
183
+ expired["expires_at"] = int(time.time()) - 1
184
+ credential.write_text(json.dumps(expired), encoding="utf-8")
185
+
186
+ result = quotas.kimi_probe()
187
+
188
+ assert result == {
189
+ "status": "unsupported",
190
+ "reason": "Kimi token expired — run any `kimi` command to refresh it",
191
+ }
192
+
193
+
194
+ def test_no_credential_is_unsupported_not_an_error(monkeypatch, tmp_path) -> None:
195
+ monkeypatch.setenv("KIMI_CODE_HOME", str(tmp_path / "kimi-code"))
196
+
197
+ result = quotas.kimi_probe()
198
+
199
+ assert result["status"] == "unsupported"
200
+ assert "no Kimi Code credential" in result["reason"]
201
+
202
+
203
+ def test_a_reshaped_payload_is_unsupported_rather_than_an_error(monkeypatch, credential) -> None:
204
+ _respond(monkeypatch, {"usage": {"limit": "100"}, "limits": []})
205
+
206
+ result = quotas.kimi_probe()
207
+
208
+ assert result["status"] == "unsupported"
209
+ assert "unrecognised" in result["reason"]
210
+
211
+
212
+ def test_a_truthy_non_object_user_is_unsupported_not_an_error(monkeypatch, credential) -> None:
213
+ payload = json.loads(json.dumps(_PAYLOAD))
214
+ payload["user"] = "gap"
215
+ _respond(monkeypatch, payload)
216
+
217
+ result = quotas.kimi_probe()
218
+
219
+ assert result["status"] == "unsupported"
220
+ assert "unrecognised" in result["reason"]
File without changes