Files
writer-work-flow/apps/api/ww_api/services/kimi_oauth.py
Yaojia Wang 765dbdfbd4 feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
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>
2026-06-20 10:39:58 +02:00

241 lines
8.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 scopeopencode-kimi
`constants.ts` 发送之kimi-cli v1.41.0 已不发但服务端仍接受。实测省略 scope 拿到的 token
缺 coding entitlement调 `api.kimi.com/coding/v1` 回 `401 Invalid Authentication`,故重新带上。
- scope 只放在 device_authorization 请求上token 交换/刷新不带 scopeOAuth 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_idkimi-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 scopedevice 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_idenv `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"])),
)