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.
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/CHANGELOG.md +8 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/PKG-INFO +5 -1
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/README.md +4 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/pyproject.toml +1 -1
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/quotas.md +3 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/cli.py +2 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/quotas.py +302 -0
- playmaker_cli-0.12.0/tests/test_quotas_kimi.py +220 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/.gitignore +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/LICENSE +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/SKILL.md +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/agent-gotchas.md +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/commands.md +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/lanes.md +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/prompt-templates.md +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/review-board.md +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/scripts/review-board.sh +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/__init__.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/__main__.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/__init__.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/agy.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/base.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/claude.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/codex.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/gemini.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/kimi.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/agents/opencode.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/config.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/notify.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/registry.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/state.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/src/playmaker/watcher.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/__init__.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/fixtures/kimi_wire.jsonl +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_agy.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_batch.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_binary.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_claude.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_codex.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_kimi.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_no_changes.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_opencode.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_permissions.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_quotas_antigravity.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_quotas_claude.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_quotas_codex.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_quotas_freshness.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_quotas_zai.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_registry.py +0 -0
- {playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/tests/test_skill.py +0 -0
- {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.
|
|
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.
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
{playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/agent-gotchas.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/prompt-templates.md
RENAMED
|
File without changes
|
{playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/references/review-board.md
RENAMED
|
File without changes
|
{playmaker_cli-0.11.0 → playmaker_cli-0.12.0}/skills/playmaker-coach/scripts/review-board.sh
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|