playmaker-cli 0.11.0__tar.gz → 0.12.1__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.1}/CHANGELOG.md +25 -0
  2. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/PKG-INFO +5 -1
  3. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/README.md +4 -0
  4. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/pyproject.toml +1 -1
  5. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/skills/playmaker-coach/SKILL.md +5 -5
  6. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/skills/playmaker-coach/references/lanes.md +3 -2
  7. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/skills/playmaker-coach/references/quotas.md +3 -0
  8. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/cli.py +2 -0
  9. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/quotas.py +302 -0
  10. playmaker_cli-0.12.1/tests/test_quotas_kimi.py +220 -0
  11. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/.gitignore +0 -0
  12. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/LICENSE +0 -0
  13. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/skills/playmaker-coach/references/agent-gotchas.md +0 -0
  14. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/skills/playmaker-coach/references/commands.md +0 -0
  15. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/skills/playmaker-coach/references/prompt-templates.md +0 -0
  16. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/skills/playmaker-coach/references/review-board.md +0 -0
  17. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/skills/playmaker-coach/scripts/review-board.sh +0 -0
  18. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/__init__.py +0 -0
  19. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/__main__.py +0 -0
  20. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/agents/__init__.py +0 -0
  21. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/agents/agy.py +0 -0
  22. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/agents/base.py +0 -0
  23. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/agents/claude.py +0 -0
  24. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/agents/codex.py +0 -0
  25. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/agents/gemini.py +0 -0
  26. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/agents/kimi.py +0 -0
  27. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/agents/opencode.py +0 -0
  28. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/config.py +0 -0
  29. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/notify.py +0 -0
  30. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/registry.py +0 -0
  31. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/state.py +0 -0
  32. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/src/playmaker/watcher.py +0 -0
  33. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/__init__.py +0 -0
  34. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/fixtures/kimi_wire.jsonl +0 -0
  35. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_agy.py +0 -0
  36. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_batch.py +0 -0
  37. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_binary.py +0 -0
  38. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_claude.py +0 -0
  39. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_codex.py +0 -0
  40. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_kimi.py +0 -0
  41. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_no_changes.py +0 -0
  42. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_opencode.py +0 -0
  43. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_permissions.py +0 -0
  44. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_quotas_antigravity.py +0 -0
  45. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_quotas_claude.py +0 -0
  46. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_quotas_codex.py +0 -0
  47. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_quotas_freshness.py +0 -0
  48. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_quotas_zai.py +0 -0
  49. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_registry.py +0 -0
  50. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_skill.py +0 -0
  51. {playmaker_cli-0.11.0 → playmaker_cli-0.12.1}/tests/test_state.py +0 -0
@@ -5,6 +5,31 @@ 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.1] - 2026-09-08
9
+
10
+ ### Fixed
11
+
12
+ - **The bundled coach skill never named the `kimi` lane.** 0.11.0 made Kimi
13
+ Code a first-class agent and `references/lanes.md` listed it, but `SKILL.md`
14
+ — the file the coach reads first, and the description Claude Code matches a
15
+ request against — still said the workers were Claude, Codex, Antigravity and
16
+ opencode; the routing cheat-sheet sent write-heavy work that can leave
17
+ Claude to `codex / agy / opencode` only, and the tier table had no senior
18
+ seat for K3. A coach following the skill to the letter would never route a
19
+ WP to the one subscription nobody else on the machine draws from. The
20
+ description, the lane list, the per-agent-traps pointer, the cheat-sheet
21
+ and the tier table now name `kimi`; CONTRIBUTING's lane and handler lists
22
+ too. As with 0.5.1, this release exists because the skill ships inside the
23
+ wheel — doc fixes are not live until published.
24
+
25
+ ## [0.12.0] - 2026-09-04
26
+
27
+ ### Added
28
+
29
+ - **Kimi Code usage in `playmaker quotas`.** The table reads Kimi Code's
30
+ managed OAuth usage endpoint and shows its rolling 5-hour Session and weekly
31
+ buckets beside the other provider pools.
32
+
8
33
  ## [0.11.0] - 2026-09-04
9
34
 
10
35
  ### 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.1
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.1"
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"
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: playmaker-coach
3
- description: Team-lead mode for coding work. Decompose the task into work packages, dispatch them to Claude/Codex/Antigravity(agy)/opencode workers through the `playmaker` CLI, then run an automatic multi-agent review board over every diff before it lands, and drive the fix cycles. Use for ANY request that will change code in more than one place, needs an independent review pass, or has two or more parts that can run at once — "implement", "add", "fix", "refactor", "wire up", "сделай", "почини", "добавь", "реализуй", "собери". NOT for answering a question, reading or explaining code, a single-line edit, or a git/ops command.
3
+ description: Team-lead mode for coding work. Decompose the task into work packages, dispatch them to Claude/Codex/Antigravity(agy)/opencode/Kimi Code(kimi) workers through the `playmaker` CLI, then run an automatic multi-agent review board over every diff before it lands, and drive the fix cycles. Use for ANY request that will change code in more than one place, needs an independent review pass, or has two or more parts that can run at once — "implement", "add", "fix", "refactor", "wire up", "сделай", "почини", "добавь", "реализуй", "собери". NOT for answering a question, reading or explaining code, a single-line edit, or a git/ops command.
4
4
  ---
5
5
 
6
6
  # playmaker-coach — you are the tech lead, not the typist
7
7
 
8
- `playmaker` dispatches sub-tasks to Codex / Antigravity (`agy`) / opencode / a sibling Claude,
9
- tracks them, and returns their threads. This skill is the judgment on top: what to split,
10
- who gets which slice, how to size it so verifying is cheap, and **how the review board runs**.
8
+ `playmaker` dispatches sub-tasks to Codex / Antigravity (`agy`) / opencode / Kimi Code (`kimi`) / a
9
+ sibling Claude, tracks them, and returns their threads. This skill is the judgment on top: what to
10
+ split, who gets which slice, how to size it so verifying is cheap, and **how the review board runs**.
11
11
 
12
12
  **The coach produces plans, prompts, verdicts and integration — not feature diffs.** Your context
13
13
  window is the most expensive resource on the table. Every file you read yourself and every line you
@@ -118,7 +118,7 @@ playmaker dispatch <agent> --model <name> --batch "$B" --cwd "$(pwd)" --prompt "
118
118
  Detached by default — that is the point; never `--sync` a whole fan-out. Always pass `--cwd`,
119
119
  `--batch` (one summary ping for the batch), and `--model` unless the profile says otherwise.
120
120
  Prompt shape: `references/prompt-templates.md`. Per-agent traps (agy scratch dir, opencode relative
121
- paths, codex model roster): `references/agent-gotchas.md`.
121
+ paths, codex model roster, kimi's K2.7 default and exit codes): `references/agent-gotchas.md`.
122
122
 
123
123
  Parallel WPs that touch the same files go in **git worktrees**, one per WP, or they will collide.
124
124
 
@@ -44,7 +44,8 @@ against the live roster, failing the dispatch on a stale name.
44
44
  | writes files, coach integrates the result directly | in-session sub-agent | write-capable, returns into context |
45
45
  | is an independent stream to monitor separately | `dispatch claude --model sonnet` | tracked, detached, spares the top bucket |
46
46
  | is heavy reasoning only the coach can do | coach | top tier, serial |
47
- | is write-heavy and can leave Claude | codex / agy / opencode | their own quotas |
47
+ | is write-heavy and can leave Claude | codex / agy / opencode / kimi | their own quotas |
48
+ | needs senior judgment on a pool nobody else on the machine draws from | `dispatch kimi -m kimi-code/k3-256k` | K3 on its own subscription; slow, so detached only |
48
49
  | needs a second strong reviewer without touching the Anthropic bucket | `dispatch agy --model <gemini-pro-high>` | near-senior judgment on an uncontended pool |
49
50
  | is bulk work with every subscription low | `dispatch opencode --model <plan>/<model>` | a separate plan, untouched by the others |
50
51
  | is mechanical and privacy-sensitive, or all quotas spent | `dispatch opencode --model <local>/<model>` | runs on this machine, costs wall-clock only |
@@ -52,7 +53,7 @@ against the live roster, failing the dispatch on a stale name.
52
53
  ## Tier-matching
53
54
 
54
55
  - **Architectural / spec judgment / cross-module integration** → top tier (coach, top-tier Codex,
55
- Gemini-Pro-high on agy for review and advice rather than implementation).
56
+ K3 on `kimi`, Gemini-Pro-high on agy for review and advice rather than implementation).
56
57
  - **Pattern-following implementation, scoped CRUD, mechanical refactor, test scaffolding, writing
57
58
  inside an existing convention** → mid tier (Claude Sonnet, Gemini-Pro-low, mid-tier Codex;
58
59
  agy's Claude models only when the roster shows a current version). Most delegated implementation lives here.
@@ -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