mirror of
https://github.com/lemon07r/opencode-kimi-full.git
synced 2026-07-18 08:05:52 +02:00
6549be2f3d
Mirrors kimi-cli's refresh_managed_models behavior: at login (and on every
token refresh) the plugin now GETs /coding/v1/models with the user's JWT,
caches the first returned {id, context_length, display_name} in auth.json,
and rewrites the wire 'model' body field to the cached id inside
loader.fetch. K2.5 accounts (server returns e.g. 'k2p5') and K2.6 accounts
(server returns 'kimi-for-coding') now share identical opencode config.
After successful login, the authorize callback prints a ready-to-paste
provider config block with the discovered values filled in.
- src/oauth.ts: added listModels() (GET /coding/v1/models).
- src/index.ts: OAuthAuth extended with model_id/context_length/model_display;
ensureFresh() refetches on refresh; loader.fetch rewrites JSON body
'model' when cached id differs from MODEL_ID; authorize callback runs
listModels() + console.logs a ready-to-paste config block.
- README.md: drop hardcoded context/output limits and K2.6-specific name;
explain auto-discovery and the K2.5/K2.6 alias mechanism.
- AGENTS.md: rule 6 rewritten to describe the wire-rewrite contract.
- test/plugin.test.ts: +3 tests (discovery on refresh persists metadata,
graceful /models failure, body.model rewrite K2.5 + K2.6 no-op);
existing 401-retry and refresh tests updated for the extra /models call.
Tested live against a real K2.6 account — listModels() returns
[{id: 'kimi-for-coding', context_length: 262144, ...}].
151 lines
4.5 KiB
TypeScript
151 lines
4.5 KiB
TypeScript
import {
|
|
API_BASE_URL,
|
|
OAUTH_CLIENT_ID,
|
|
OAUTH_DEVICE_AUTH_URL,
|
|
OAUTH_DEVICE_GRANT,
|
|
OAUTH_REFRESH_GRANT,
|
|
OAUTH_SCOPE,
|
|
OAUTH_TOKEN_URL,
|
|
} from "./constants.ts"
|
|
import { kimiHeaders } from "./headers.ts"
|
|
|
|
export type DeviceAuth = {
|
|
device_code: string
|
|
user_code: string
|
|
verification_uri: string
|
|
verification_uri_complete?: string
|
|
expires_in: number
|
|
interval: number
|
|
}
|
|
|
|
export type TokenResponse = {
|
|
access_token: string
|
|
refresh_token: string
|
|
token_type: string
|
|
expires_in: number
|
|
}
|
|
|
|
function formBody(params: Record<string, string>): string {
|
|
return new URLSearchParams(params).toString()
|
|
}
|
|
|
|
async function postForm<T>(url: string, params: Record<string, string>): Promise<T> {
|
|
const res = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
...kimiHeaders(),
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: formBody(params),
|
|
})
|
|
const text = await res.text()
|
|
let json: any
|
|
try {
|
|
json = text ? JSON.parse(text) : {}
|
|
} catch {
|
|
throw new Error(`kimi oauth: non-JSON response from ${url} (status ${res.status}): ${text.slice(0, 200)}`)
|
|
}
|
|
if (!res.ok) {
|
|
const code = json.error ?? res.status
|
|
const msg = json.error_description ?? text
|
|
const err = new Error(`kimi oauth ${code}: ${msg}`) as Error & { code?: string; status?: number }
|
|
err.code = json.error
|
|
err.status = res.status
|
|
throw err
|
|
}
|
|
return json as T
|
|
}
|
|
|
|
export async function startDeviceAuth(): Promise<DeviceAuth> {
|
|
return postForm<DeviceAuth>(OAUTH_DEVICE_AUTH_URL, {
|
|
client_id: OAUTH_CLIENT_ID,
|
|
scope: OAUTH_SCOPE,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Polls the token endpoint until the user approves the device code, the
|
|
* device code expires, or an unexpected error occurs. Honors `authorization_pending`
|
|
* and `slow_down` per RFC 8628.
|
|
*/
|
|
export async function pollDeviceToken(device: DeviceAuth): Promise<TokenResponse> {
|
|
let interval = Math.max(1, device.interval || 5) * 1000
|
|
const deadline = Date.now() + device.expires_in * 1000
|
|
while (Date.now() < deadline) {
|
|
await new Promise((r) => setTimeout(r, interval))
|
|
try {
|
|
return await postForm<TokenResponse>(OAUTH_TOKEN_URL, {
|
|
client_id: OAUTH_CLIENT_ID,
|
|
device_code: device.device_code,
|
|
grant_type: OAUTH_DEVICE_GRANT,
|
|
})
|
|
} catch (err) {
|
|
const code = (err as { code?: string }).code
|
|
if (code === "authorization_pending") continue
|
|
if (code === "slow_down") {
|
|
interval += 5_000
|
|
continue
|
|
}
|
|
if (code === "expired_token") throw new Error("kimi oauth: device code expired — run login again")
|
|
throw err
|
|
}
|
|
}
|
|
throw new Error("kimi oauth: device code expired before the user approved it")
|
|
}
|
|
|
|
export async function refreshToken(refresh: string): Promise<TokenResponse> {
|
|
return postForm<TokenResponse>(OAUTH_TOKEN_URL, {
|
|
client_id: OAUTH_CLIENT_ID,
|
|
refresh_token: refresh,
|
|
grant_type: OAUTH_REFRESH_GRANT,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Shape of each entry in `GET /coding/v1/models`. Field set mirrors what
|
|
* kimi-cli reads — see research/kimi-cli/src/kimi_cli/auth/platforms.py
|
|
* `_list_models`. Unused-by-this-plugin flags are kept optional for
|
|
* documentation; the wire response carries them either way.
|
|
*/
|
|
export type KimiModelInfo = {
|
|
id: string
|
|
display_name?: string
|
|
context_length?: number
|
|
supports_reasoning?: boolean
|
|
supports_image_in?: boolean
|
|
supports_video_in?: boolean
|
|
}
|
|
|
|
/**
|
|
* Calls `GET {API_BASE_URL}/models` with the user's JWT and returns the
|
|
* server's authoritative model list for this account. Different account
|
|
* tiers see different slugs (K2.5 accounts may see `k2p5`, K2.6 accounts
|
|
* see `kimi-for-coding`); the `id` and `context_length` here are the only
|
|
* truth about what the user is actually entitled to.
|
|
*
|
|
* kimi-cli calls this on login and on every successful token refresh
|
|
* (see `refresh_managed_models` in platforms.py). We do the same.
|
|
*/
|
|
export async function listModels(accessToken: string): Promise<KimiModelInfo[]> {
|
|
const res = await fetch(`${API_BASE_URL}/models`, {
|
|
headers: {
|
|
...kimiHeaders(),
|
|
Authorization: `Bearer ${accessToken}`,
|
|
Accept: "application/json",
|
|
},
|
|
})
|
|
const text = await res.text()
|
|
if (!res.ok) {
|
|
throw new Error(`kimi list-models ${res.status}: ${text.slice(0, 200)}`)
|
|
}
|
|
let json: any
|
|
try {
|
|
json = JSON.parse(text)
|
|
} catch {
|
|
throw new Error(`kimi list-models: non-JSON response: ${text.slice(0, 200)}`)
|
|
}
|
|
const data = Array.isArray(json?.data) ? json.data : []
|
|
return data.filter((m: any) => typeof m?.id === "string") as KimiModelInfo[]
|
|
}
|