M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。 M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变); 网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则; 前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。 K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径; coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。 本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400); GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化; 字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。 门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
241 lines
8.8 KiB
Python
241 lines
8.8 KiB
Python
"""Kimi Code OAuth device-flow 客户端(K1.3 / PROGRESS K1)。
|
||
|
||
Kimi 订阅 plan 走 OAuth 2.0 **device authorization flow**(RFC 8628):
|
||
|
||
1. `start_device_authorization` → `POST .../device_authorization`,拿 `device_code` +
|
||
`user_code` + `verification_uri`(用户在浏览器授权)+ 轮询 `interval`/过期 `expires_in`。
|
||
2. `poll_token`(一次尝试,调用方按 `interval` 循环)→ `POST .../token`
|
||
(grant=device_code);`authorization_pending` → 继续轮询、`slow_down` → 增大间隔、
|
||
`expired_token`/`access_denied` → 停止失败;成功 → access/refresh token。
|
||
3. `refresh` → 同 token 端点(grant=refresh_token),换新的 access/refresh token。
|
||
|
||
**httpx 注入**:所有 HTTP 经注入的 `AsyncHttpClient` Protocol(= `httpx.AsyncClient`
|
||
的 `.post` 子集)——测试注 fake,**绝不联网**。
|
||
|
||
**token 不落明文**:`TokenSet` 序列化为 JSON 串经 Fernet 加密入 `provider_credentials.oauth_enc`
|
||
(`encrypt_oauth_bundle`/`decrypt_oauth_bundle`),明文 token 绝不进日志/响应/job 结果。
|
||
|
||
研究确认(对照 `github.com/ooojustin/opencode-kimi` `constants.ts` + `picassio/pi-kimi-coder`):
|
||
- client_id `17e5f671-d194-4dfb-9706-5516cb48c098`(env `KIMI_CLIENT_ID` 可覆盖)。
|
||
- device authorization **带 `scope=kimi-code`**(coding-agent OAuth scope):opencode-kimi
|
||
`constants.ts` 发送之;kimi-cli v1.41.0 已不发但服务端仍接受。实测省略 scope 拿到的 token
|
||
缺 coding entitlement,调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`,故重新带上。
|
||
- scope 只放在 device_authorization 请求上;token 交换/刷新不带 scope(OAuth device flow 惯例)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from dataclasses import dataclass
|
||
from datetime import UTC, datetime, timedelta
|
||
from typing import Any, Protocol
|
||
|
||
from ww_shared import AppError, ErrorCode
|
||
|
||
from ww_api.security.credentials import decrypt_api_key, encrypt_api_key
|
||
|
||
#: Kimi OAuth 端点(device authorization + token)。
|
||
KIMI_AUTH_BASE_URL = "https://auth.kimi.com/api/oauth"
|
||
DEVICE_AUTHORIZATION_URL = f"{KIMI_AUTH_BASE_URL}/device_authorization"
|
||
TOKEN_URL = f"{KIMI_AUTH_BASE_URL}/token"
|
||
|
||
#: 默认 client_id(kimi-cli 公开常量;env `KIMI_CLIENT_ID` 可覆盖)。
|
||
DEFAULT_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098"
|
||
ENV_CLIENT_ID = "KIMI_CLIENT_ID"
|
||
|
||
GRANT_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
|
||
GRANT_REFRESH_TOKEN = "refresh_token"
|
||
|
||
#: coding-agent OAuth scope(device authorization 专用;缺它 token 无 coding entitlement)。
|
||
KIMI_CODE_SCOPE = "kimi-code"
|
||
|
||
#: device flow 默认轮询间隔(秒)——服务端未给 `interval` 时的兜底。
|
||
DEFAULT_POLL_INTERVAL = 5
|
||
|
||
#: token 刷新触发缓冲(秒):剩余寿命低于 max(300, 0.5*expires_in) 即刷新。
|
||
MIN_REFRESH_BUFFER_SECONDS = 300
|
||
|
||
|
||
def client_id() -> str:
|
||
"""当前 OAuth client_id(env `KIMI_CLIENT_ID` 优先,否则公开默认值)。"""
|
||
return os.environ.get(ENV_CLIENT_ID) or DEFAULT_CLIENT_ID
|
||
|
||
|
||
class AsyncHttpClient(Protocol):
|
||
"""`run_job`/服务对 HTTP 客户端的**最小**依赖(= `httpx.AsyncClient.post` 子集)。
|
||
|
||
便于测试注 fake(绝不联网)。运行时传 `httpx.AsyncClient`。
|
||
"""
|
||
|
||
async def post(self, url: str, *, data: dict[str, str]) -> HttpResponse: ...
|
||
|
||
|
||
class HttpResponse(Protocol):
|
||
"""HTTP 响应的最小读接口(`httpx.Response` 满足之)。"""
|
||
|
||
status_code: int
|
||
|
||
def json(self) -> Any: ...
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class DeviceAuth:
|
||
"""device authorization 响应(用户面:展示 user_code + 打开 verification_uri)。"""
|
||
|
||
device_code: str
|
||
user_code: str
|
||
verification_uri: str
|
||
verification_uri_complete: str | None
|
||
expires_in: int
|
||
interval: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class TokenSet:
|
||
"""OAuth token 三元组(access 短期 / refresh 长期 / 服务端驱动过期时刻 UTC)。"""
|
||
|
||
access_token: str
|
||
refresh_token: str
|
||
expires_at: datetime
|
||
|
||
|
||
class AuthorizationPending(Exception):
|
||
"""device flow 轮询:用户尚未授权(继续轮询)。"""
|
||
|
||
|
||
class SlowDown(Exception):
|
||
"""device flow 轮询:轮询过快(增大 interval 后继续)。"""
|
||
|
||
|
||
def _now() -> datetime:
|
||
return datetime.now(UTC)
|
||
|
||
|
||
def _expires_at(expires_in: int) -> datetime:
|
||
return _now() + timedelta(seconds=max(0, expires_in))
|
||
|
||
|
||
def needs_refresh(token: TokenSet, *, now: datetime | None = None) -> bool:
|
||
"""判定 access token 是否临近过期(剩余寿命 < `MIN_REFRESH_BUFFER_SECONDS`)。
|
||
|
||
建网关时按需刷新(§token 刷新启发式);过期时刻已是服务端驱动的绝对时刻,故只需
|
||
与缓冲比较(无需原始 expires_in:缓冲固定 300s,对 ~15min access 足够)。
|
||
"""
|
||
current = now or _now()
|
||
remaining = (token.expires_at - current).total_seconds()
|
||
return remaining < MIN_REFRESH_BUFFER_SECONDS
|
||
|
||
|
||
async def start_device_authorization(http: AsyncHttpClient) -> DeviceAuth:
|
||
"""发起 device authorization(带 `scope=kimi-code` 以获取 coding entitlement)。"""
|
||
resp = await http.post(
|
||
DEVICE_AUTHORIZATION_URL,
|
||
data={"client_id": client_id(), "scope": KIMI_CODE_SCOPE},
|
||
)
|
||
if resp.status_code >= 400:
|
||
raise AppError(
|
||
ErrorCode.LLM_UNAVAILABLE,
|
||
"Kimi 设备授权请求失败",
|
||
{"status": resp.status_code},
|
||
)
|
||
body = resp.json()
|
||
return DeviceAuth(
|
||
device_code=str(body["device_code"]),
|
||
user_code=str(body["user_code"]),
|
||
verification_uri=str(body["verification_uri"]),
|
||
verification_uri_complete=(
|
||
str(body["verification_uri_complete"])
|
||
if body.get("verification_uri_complete")
|
||
else None
|
||
),
|
||
expires_in=int(body.get("expires_in", 0)),
|
||
interval=int(body.get("interval", DEFAULT_POLL_INTERVAL)),
|
||
)
|
||
|
||
|
||
def _token_set_from_body(body: dict[str, Any]) -> TokenSet:
|
||
return TokenSet(
|
||
access_token=str(body["access_token"]),
|
||
refresh_token=str(body["refresh_token"]),
|
||
expires_at=_expires_at(int(body.get("expires_in", 0))),
|
||
)
|
||
|
||
|
||
async def poll_token(http: AsyncHttpClient, device_code: str) -> TokenSet:
|
||
"""轮询一次 token 端点(调用方按 interval 循环)。
|
||
|
||
`authorization_pending` → 抛 `AuthorizationPending`(继续轮询);
|
||
`slow_down` → 抛 `SlowDown`(增大 interval);
|
||
`expired_token`/`access_denied`/其它 → 抛 `AppError`(停止失败);
|
||
成功 → `TokenSet`。
|
||
"""
|
||
resp = await http.post(
|
||
TOKEN_URL,
|
||
data={
|
||
"grant_type": GRANT_DEVICE_CODE,
|
||
"device_code": device_code,
|
||
"client_id": client_id(),
|
||
},
|
||
)
|
||
body = resp.json()
|
||
if resp.status_code >= 400 or body.get("error"):
|
||
error = str(body.get("error", "unknown_error"))
|
||
if error == "authorization_pending":
|
||
raise AuthorizationPending
|
||
if error == "slow_down":
|
||
raise SlowDown
|
||
# expired_token / access_denied / 其它 → 终止失败。
|
||
raise AppError(
|
||
ErrorCode.LLM_UNAVAILABLE,
|
||
f"Kimi 设备授权失败:{error}",
|
||
{"error": error},
|
||
)
|
||
return _token_set_from_body(body)
|
||
|
||
|
||
async def refresh(http: AsyncHttpClient, refresh_token: str) -> TokenSet:
|
||
"""用 refresh_token 换新 token 组(access 临近过期时建网关触发)。"""
|
||
resp = await http.post(
|
||
TOKEN_URL,
|
||
data={
|
||
"grant_type": GRANT_REFRESH_TOKEN,
|
||
"refresh_token": refresh_token,
|
||
"client_id": client_id(),
|
||
},
|
||
)
|
||
body = resp.json()
|
||
if resp.status_code >= 400 or body.get("error"):
|
||
error = str(body.get("error", "unknown_error"))
|
||
raise AppError(
|
||
ErrorCode.LLM_UNAVAILABLE,
|
||
f"Kimi token 刷新失败:{error}",
|
||
{"error": error},
|
||
)
|
||
return _token_set_from_body(body)
|
||
|
||
|
||
def encrypt_oauth_bundle(token: TokenSet, *, key: str) -> bytes:
|
||
"""把 `TokenSet` 序列化为 JSON 串并 Fernet 加密为 `oauth_enc` 密文。
|
||
|
||
JSON 形 `{access_token, refresh_token, expires_at(ISO8601)}`——明文 token 绝不出此函数。
|
||
"""
|
||
bundle = json.dumps(
|
||
{
|
||
"access_token": token.access_token,
|
||
"refresh_token": token.refresh_token,
|
||
"expires_at": token.expires_at.isoformat(),
|
||
}
|
||
)
|
||
return encrypt_api_key(bundle, key=key)
|
||
|
||
|
||
def decrypt_oauth_bundle(blob: bytes, *, key: str) -> TokenSet:
|
||
"""解密 `oauth_enc` 密文回 `TokenSet`(reuse Fernet helper,工作在 str 上)。"""
|
||
bundle = json.loads(decrypt_api_key(blob, key=key))
|
||
return TokenSet(
|
||
access_token=str(bundle["access_token"]),
|
||
refresh_token=str(bundle["refresh_token"]),
|
||
expires_at=datetime.fromisoformat(str(bundle["expires_at"])),
|
||
)
|