@oh-my-pi/pi-catalog 18.13.2 → 18.13.3

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.
@@ -26,6 +26,9 @@ const CREDENTIAL_SCOPED_MODEL_CACHE_PROVIDERS: Readonly<Record<string, true>> =
26
26
  // than from the synchronous, credential-less startup read.
27
27
  "singularityapi-dev": true,
28
28
  "singularityapi-tech": true,
29
+ // Kimi Code's roster is plan-gated and discovery is authoritative, so a
30
+ // shared namespace would serve one plan's ids to another plan's credential.
31
+ "kimi-code": true,
29
32
  };
30
33
 
31
34
  /** Whether a provider's model-cache namespace requires its resolved credential. */
@@ -37,6 +40,8 @@ export function getDefaultModelDiscoveryBaseUrl(providerId: string): string | un
37
40
  switch (providerId) {
38
41
  case "charm-hyper":
39
42
  return CHARM_HYPER_API_BASE_URL;
43
+ case "kimi-code":
44
+ return "https://api.kimi.com/coding/v1";
40
45
  case "meta":
41
46
  case "muse-code":
42
47
  return "https://api.meta.ai/v1";
@@ -71,11 +76,11 @@ export function resolveOllamaModelCacheProviderId(providerId: string, baseUrl?:
71
76
  }
72
77
 
73
78
  /**
74
- * The `sub` claim of a Cursor access token. Cursor access tokens are JWTs
75
- * that `refreshCursorToken` rotates (new `exp`/`iat`) for the same account,
79
+ * The `sub` claim of a JWT access token. Cursor and Kimi Code access tokens
80
+ * are JWTs that refresh rotates (new `exp`/`iat`/`jti`) for the same account,
76
81
  * so the subject is the stable account scope. Non-JWT keys return undefined.
77
82
  */
78
- function cursorCredentialSubject(apiKey: string): string | undefined {
83
+ function jwtCredentialSubject(apiKey: string): string | undefined {
79
84
  const parts = apiKey.split(".");
80
85
  if (parts.length !== 3 || !parts[1]) return undefined;
81
86
  try {
@@ -112,7 +117,7 @@ export function resolveModelCacheProviderId(providerId: string, options: ModelCa
112
117
  // mark the account default `isProviderDefault`, which startup now honors.
113
118
  const baseUrl = (options.baseUrl ?? CURSOR_DEFAULT_BASE_URL).replace(/\/+$/, "");
114
119
  const apiKey = options.apiKey ?? "";
115
- const scope = `${cursorCredentialSubject(apiKey) ?? apiKey}\u0000${baseUrl}`;
120
+ const scope = `${jwtCredentialSubject(apiKey) ?? apiKey}\u0000${baseUrl}`;
116
121
  return `cursor:rich-models-v5:${Bun.hash(scope).toString(36)}`;
117
122
  }
118
123
  case "charm-hyper": {
@@ -132,6 +137,17 @@ export function resolveModelCacheProviderId(providerId: string, options: ModelCa
132
137
  // `/v1`-suffixed one; both must land on one namespace.
133
138
  return `charm-hyper:models-v1:${Bun.hash(normalizeCharmHyperBaseUrl(options.baseUrl)).toString(36)}`;
134
139
  }
140
+ case "kimi-code": {
141
+ // The `/coding/v1/models` roster depends on the subscription plan behind
142
+ // the credential, and discovery is authoritative: a namespace without the
143
+ // credential would serve the previous plan's roster for the full TTL,
144
+ // including ids the current plan cannot call. OAuth access tokens rotate on
145
+ // refresh, so the scope is the token's account subject, not the token.
146
+ const baseUrl = options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!;
147
+ const apiKey = options.apiKey ?? "";
148
+ const scope = `${jwtCredentialSubject(apiKey) ?? apiKey}\u0000${baseUrl}`;
149
+ return `kimi-code:models-v1:${Bun.hash(scope).toString(36)}`;
150
+ }
135
151
  case "muse-code": {
136
152
  const baseUrl = options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!;
137
153
  const scope = `${options.apiKey ?? ""}\u0000${baseUrl}`;
@@ -160,7 +176,8 @@ export function resolveModelCacheProviderId(providerId: string, options: ModelCa
160
176
  }
161
177
  case "litellm": {
162
178
  const baseUrl = options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!;
163
- // rich-v13 moves `mode: decisions` groups off chat onto the Decisions runner.
179
+ // rich-v14 fetches every /v2/model/info page rather than caching only
180
+ // its first 50 models. rich-v13 moves `mode: decisions` groups off chat.
164
181
  // rich-v12 invalidates namespaced ids that missed bare catalog references.
165
182
  // rich-v11 excluded ClinePass gateway metadata (issue #10932). rich-v10
166
183
  // filtered known non-conversational LiteLLM modes, unioned compat across
@@ -168,7 +185,7 @@ export function resolveModelCacheProviderId(providerId: string, options: ModelCa
168
185
  // declaration into it; earlier versions invalidated rows whose
169
186
  // `compatConfig` retained a colliding bundled model's provider-specific
170
187
  // transport (e.g. Fireworks `wireModelIdMode`) (issue #9938).
171
- return `litellm:rich-v13:${Bun.hash(baseUrl).toString(36)}`;
188
+ return `litellm:rich-v14:${Bun.hash(baseUrl).toString(36)}`;
172
189
  }
173
190
  case "gmi-cloud":
174
191
  case "siliconflow":
@@ -4221,9 +4221,11 @@ export function kimiCodeModelManagerOptions(
4221
4221
  config?: KimiCodeModelManagerConfig,
4222
4222
  ): ModelManagerOptions<"openai-completions"> {
4223
4223
  const apiKey = config?.apiKey;
4224
- const baseUrl = config?.baseUrl ?? "https://api.kimi.com/coding/v1";
4224
+ const baseUrl = config?.baseUrl ?? getDefaultModelDiscoveryBaseUrl("kimi-code")!;
4225
4225
  return {
4226
4226
  providerId: "kimi-code",
4227
+ cacheProviderId: resolveModelCacheProviderId("kimi-code", { apiKey, baseUrl }),
4228
+ dynamicModelsAuthoritative: true,
4227
4229
  ...(apiKey && {
4228
4230
  fetchDynamicModels: () =>
4229
4231
  fetchOpenAICompatibleModels({
@@ -5678,7 +5680,7 @@ type LiteLLMRichEndpointModel<TApi extends Api> = {
5678
5680
  };
5679
5681
  type LiteLLMRichEndpointFailure = {
5680
5682
  endpoint: string;
5681
- reason: "http-status" | "invalid-json" | "network-error";
5683
+ reason: "http-status" | "invalid-json" | "invalid-pagination" | "network-error";
5682
5684
  status?: number;
5683
5685
  error?: unknown;
5684
5686
  };
@@ -5691,6 +5693,8 @@ type LiteLLMRichEndpointResult<TApi extends Api> =
5691
5693
  | { failure: LiteLLMRichEndpointFailure };
5692
5694
 
5693
5695
  const LITELLM_RICH_ENDPOINTS = ["/model_group/info", "/v2/model/info", "/model/info", "/v1/model/info"] as const;
5696
+ // Bound paginated management responses before trusting their advertised page count.
5697
+ const LITELLM_MAX_RICH_PAGES = 100;
5694
5698
  export const OPENAI_COMPAT_DISCOVERY_DEFAULT_CONTEXT_WINDOW = 128_000;
5695
5699
  export const OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS = 32_768;
5696
5700
  const UNKNOWN_PROXY_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const;
@@ -6170,29 +6174,83 @@ async function fetchLiteLLMRichEndpoint<TApi extends Api>(
6170
6174
  if (options.apiKey) {
6171
6175
  requestHeaders.Authorization = `Bearer ${options.apiKey}`;
6172
6176
  }
6173
- let response: Response;
6174
- try {
6175
- response = await fetchImpl(`${managementBaseUrl}${endpoint}`, {
6176
- method: "GET",
6177
- headers: requestHeaders,
6178
- signal,
6179
- });
6180
- } catch (error) {
6181
- return { failure: { endpoint, reason: "network-error", error } };
6182
- }
6183
- if (!response.ok) {
6184
- return response.status === 404 ? null : { failure: { endpoint, reason: "http-status", status: response.status } };
6185
- }
6186
- let payload: unknown;
6187
- try {
6188
- payload = await response.json();
6189
- } catch (error) {
6190
- return { failure: { endpoint, reason: "invalid-json", status: response.status, error } };
6177
+ const fetchPage = async (
6178
+ page?: number,
6179
+ ): Promise<{ payload: unknown; status: number } | { failure: LiteLLMRichEndpointFailure } | null> => {
6180
+ let response: Response;
6181
+ try {
6182
+ response = await fetchImpl(`${managementBaseUrl}${endpoint}${page === undefined ? "" : `?page=${page}`}`, {
6183
+ method: "GET",
6184
+ headers: requestHeaders,
6185
+ signal,
6186
+ });
6187
+ } catch (error) {
6188
+ return { failure: { endpoint, reason: "network-error", error } };
6189
+ }
6190
+ if (!response.ok) {
6191
+ return response.status === 404 && page === undefined
6192
+ ? null
6193
+ : { failure: { endpoint, reason: "http-status", status: response.status } };
6194
+ }
6195
+ try {
6196
+ return { payload: await response.json(), status: response.status };
6197
+ } catch (error) {
6198
+ return { failure: { endpoint, reason: "invalid-json", status: response.status, error } };
6199
+ }
6200
+ };
6201
+ const firstPage = await fetchPage();
6202
+ if (!firstPage || "failure" in firstPage) {
6203
+ return firstPage;
6191
6204
  }
6192
- const entries = extractLiteLLMRichEntries(payload);
6205
+ const entries = extractLiteLLMRichEntries(firstPage.payload);
6193
6206
  if (!entries || entries.length === 0) {
6194
6207
  return null;
6195
6208
  }
6209
+ // /v2/model/info returns {current_page, total_pages, data}; older
6210
+ // endpoints and unpaginated /v2 proxies must keep their existing behavior.
6211
+ if (
6212
+ endpoint === "/v2/model/info" &&
6213
+ isRecord(firstPage.payload) &&
6214
+ ("current_page" in firstPage.payload || "total_pages" in firstPage.payload)
6215
+ ) {
6216
+ const { current_page: currentPage, total_pages: totalPages, total_count: totalCount } = firstPage.payload;
6217
+ const invalidPage = (status: number): LiteLLMRichEndpointResult<TApi> => ({
6218
+ failure: { endpoint, reason: "invalid-pagination", status },
6219
+ });
6220
+ if (
6221
+ currentPage !== 1 ||
6222
+ typeof totalPages !== "number" ||
6223
+ !Number.isSafeInteger(totalPages) ||
6224
+ totalPages < 1 ||
6225
+ totalPages > LITELLM_MAX_RICH_PAGES ||
6226
+ (totalCount !== undefined &&
6227
+ (typeof totalCount !== "number" || !Number.isSafeInteger(totalCount) || totalCount < entries.length))
6228
+ ) {
6229
+ return invalidPage(firstPage.status);
6230
+ }
6231
+ const remainingPages = await Promise.all(
6232
+ Array.from({ length: totalPages - 1 }, (_, index) => fetchPage(index + 2)),
6233
+ );
6234
+ for (const [index, nextPage] of remainingPages.entries()) {
6235
+ if (!nextPage || "failure" in nextPage) {
6236
+ return nextPage ?? invalidPage(firstPage.status);
6237
+ }
6238
+ const nextEntries = extractLiteLLMRichEntries(nextPage.payload);
6239
+ if (
6240
+ !isRecord(nextPage.payload) ||
6241
+ nextPage.payload.current_page !== index + 2 ||
6242
+ nextPage.payload.total_pages !== totalPages ||
6243
+ (totalCount !== undefined && nextPage.payload.total_count !== totalCount) ||
6244
+ !nextEntries?.length
6245
+ ) {
6246
+ return invalidPage(nextPage.status);
6247
+ }
6248
+ entries.push(...nextEntries);
6249
+ }
6250
+ if (totalCount !== undefined && entries.length !== totalCount) {
6251
+ return invalidPage(firstPage.status);
6252
+ }
6253
+ }
6196
6254
  const deduped = new Map<string, LiteLLMRichEndpointModel<TApi>>();
6197
6255
  const excludedModelIds = new Set<string>();
6198
6256
  for (const entry of entries) {
@@ -6349,8 +6407,10 @@ export function litellmModelManagerOptions(config?: LiteLLMModelManagerConfig):
6349
6407
  const baseUrl = config?.baseUrl ?? getDefaultModelDiscoveryBaseUrl("litellm")!;
6350
6408
  return {
6351
6409
  providerId: "litellm",
6352
- // rich-v13 routes `mode: decisions` groups to the Decisions runner as judge
6353
- // rows. rich-v12 invalidates namespaced proxy ids that missed bare catalog
6410
+ // rich-v14 fetches all /v2/model/info pages so cached proxy rosters
6411
+ // include models beyond page one. rich-v13 routes `mode: decisions`
6412
+ // groups to the Decisions runner as judge rows.
6413
+ // rich-v12 invalidates namespaced proxy ids that missed bare catalog
6354
6414
  // references. rich-v11 excluded ClinePass gateway metadata (issue #10932).
6355
6415
  // rich-v10 filtered known non-conversational LiteLLM modes, keyed the
6356
6416
  // deployment's `supports_vision` declaration into cached compat, and
@@ -94,11 +94,10 @@ export function openaiCodexModelManagerOptions(
94
94
  * {@link unionAccountCatalogs}.
95
95
  *
96
96
  * Returns `null` when any account's fetch failed transiently, so a partial list
97
- * cannot replace the previous or bundled authoritative catalog. An account
98
- * whose credential the backend rejected outright (401/403 — revoked or
99
- * deauthorized) contributes nothing and is skipped instead: it would otherwise
100
- * veto every sibling account's models until the user removes it. If no account
101
- * produced a catalog, discovery aborts the same way.
97
+ * cannot replace the previous or bundled authoritative catalog. A definitive
98
+ * account denial (401/403 auth or 402 payment) contributes nothing and is skipped
99
+ * instead: it would otherwise veto every sibling account's models until the
100
+ * account recovers. If no account produced a catalog, discovery aborts.
102
101
  */
103
102
  function unionCodexModels(
104
103
  results: readonly { accountId: string | undefined; result: CodexModelDiscoveryResult | null }[],
@@ -107,7 +106,7 @@ function unionCodexModels(
107
106
  for (const { accountId, result } of results) {
108
107
  if (!result) return null;
109
108
  if (result.rejectedStatus !== undefined) {
110
- logger.warn("Codex model discovery skipped an account whose credential was rejected", {
109
+ logger.warn("Codex model discovery skipped an account denied by the backend", {
111
110
  accountId,
112
111
  status: result.rejectedStatus,
113
112
  });