File size: 7,170 Bytes
f0634fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | /**
* Managed-platform profile fetch / parse.
*
* Only `managed:kimi-code` is supported today. The platform exposes a
* `/me` endpoint that returns a payload of the shape:
*
* {
* "user_id": "u_123",
* "nickname": "moonwalker",
* "status": "USER_STATUS_NORMAL",
* "region": "REGION_CN",
* "user_level": 30,
* "user_level_name": "Vivace",
* "domain": 1,
* "domain_name": "DOMAIN_EXAMPLE",
* "global_id": "u_123",
* "avatar": "https://example.com/avatar.png",
* "email": "user@example.com",
* "phone": { "country_code": "86", "number": "176****0000" },
* "created_time": "2026-06-11T13:26:47.561184Z",
* "last_login_time": "2026-07-16T03:12:03.033412Z"
* }
*
* `user_id` / `nickname` / `status` / `region` / `user_level` /
* `user_level_name` / `domain` / `domain_name` are always serialized
* (possibly as zero values); the rest are optional (`phone` a nested
* record, the timestamps RFC3339 strings passed through verbatim). The
* parser normalizes the payload into a structured, camelCase domain
* model; presentation is left to the consumer.
*
* The camelCase zod schemas below are the single source of truth for the
* domain model and double as the daemon REST wire contract: new daemon
* endpoints speak the camelCase domain shape directly, while legacy
* snake_case endpoints are unaffected.
*/
import { z } from 'zod';
import { readApiErrorMessage } from './api-error';
import { kimiCodeBaseUrl } from './managed-usage';
import { isRecord } from './utils';
// The cloud path stays `/me` (owned by the backend); only the local
// naming moved to "userinfo".
export function kimiCodeUserInfoUrl(): string {
return `${kimiCodeBaseUrl()}/me`;
}
export const managedUserInfoPhoneSchema = z.object({
countryCode: z.string(),
number: z.string(),
});
export type ManagedUserInfoPhone = z.infer<typeof managedUserInfoPhoneSchema>;
export const managedUserInfoSchema = z.object({
userId: z.string(),
nickname: z.string(),
status: z.string(),
region: z.string(),
userLevel: z.number().int(),
userLevelName: z.string(),
domain: z.number().int(),
domainName: z.string(),
globalId: z.string().optional(),
bio: z.string().optional(),
avatar: z.string().optional(),
username: z.string().optional(),
email: z.string().optional(),
phone: managedUserInfoPhoneSchema.optional(),
/** Backend RFC3339 timestamp, passed through verbatim. */
createdTime: z.string().optional(),
/** Backend RFC3339 timestamp, passed through verbatim. */
lastLoginTime: z.string().optional(),
});
export type ManagedUserInfo = z.infer<typeof managedUserInfoSchema>;
const managedUserInfoOkSchema = z.object({
kind: z.literal('ok'),
userInfo: managedUserInfoSchema,
});
const managedUserInfoErrorSchema = z.object({
kind: z.literal('error'),
message: z.string(),
status: z.number().int().optional(),
});
export const managedUserInfoResultSchema = z.discriminatedUnion('kind', [
managedUserInfoOkSchema,
managedUserInfoErrorSchema,
]);
export type ManagedUserInfoResult = z.infer<typeof managedUserInfoResultSchema>;
/**
* Lenient parse: anything that is not a record, or lacks a `user_id`
* string, is malformed; every other field degrades independently.
*/
export function parseManagedUserInfoPayload(payload: unknown): ManagedUserInfo | null {
if (!isRecord(payload)) return null;
const userId = stringField(payload, 'user_id');
if (userId === undefined) return null;
return {
userId,
nickname: stringField(payload, 'nickname') ?? '',
status: stringField(payload, 'status') ?? '',
region: stringField(payload, 'region') ?? '',
userLevel: intField(payload, 'user_level') ?? 0,
userLevelName: stringField(payload, 'user_level_name') ?? '',
domain: intField(payload, 'domain') ?? 0,
domainName: stringField(payload, 'domain_name') ?? '',
globalId: stringField(payload, 'global_id'),
bio: stringField(payload, 'bio'),
avatar: stringField(payload, 'avatar'),
username: stringField(payload, 'username'),
email: stringField(payload, 'email'),
phone: parsePhone(payload['phone']),
createdTime: stringField(payload, 'created_time'),
lastLoginTime: stringField(payload, 'last_login_time'),
};
}
function parsePhone(raw: unknown): ManagedUserInfoPhone | undefined {
if (!isRecord(raw)) return undefined;
const countryCode = stringField(raw, 'country_code') ?? '';
const number = stringField(raw, 'number') ?? '';
if (countryCode === '' && number === '') return undefined;
return { countryCode, number };
}
function stringField(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function intField(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
if (typeof value === 'number') {
return Number.isFinite(value) ? Math.trunc(value) : undefined;
}
if (typeof value === 'string') {
const n = Number(value);
return Number.isFinite(n) ? Math.trunc(n) : undefined;
}
return undefined;
}
// ββ HTTP fetch ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export interface FetchManagedUserInfoResult {
readonly kind: 'ok';
readonly userInfo: ManagedUserInfo;
}
export interface FetchManagedUserInfoError {
readonly kind: 'error';
readonly status?: number;
readonly message: string;
}
export async function fetchManagedUserInfo(
url: string,
accessToken: string,
opts: { timeoutMs?: number } = {},
): Promise<FetchManagedUserInfoResult | FetchManagedUserInfoError> {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
}, opts.timeoutMs ?? 8000);
try {
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
signal: controller.signal,
});
if (!res.ok) {
const status = res.status;
const hint =
status === 401
? 'Authorization failed. Please check your API key (try /login).'
: status === 404
? 'Profile endpoint not available. Try Kimi For Coding.'
: `Failed to fetch profile: HTTP ${String(status)}`;
return { kind: 'error', status, message: await readApiErrorMessage(res, hint) };
}
const json: unknown = await res.json();
const me = parseManagedUserInfoPayload(json);
if (me === null) {
return { kind: 'error', message: 'Failed to fetch profile: malformed response.' };
}
return { kind: 'ok', userInfo: me };
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return { kind: 'error', message: 'Failed to fetch profile: request timed out.' };
}
const msg = error instanceof Error ? error.message : String(error);
return { kind: 'error', message: `Failed to fetch profile: ${msg}` };
} finally {
clearTimeout(timer);
}
}
|