@juspay/neurolink 12.22.1 → 12.23.1

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/dist/adapters/providerImageAdapter.js +16 -11
  3. package/dist/browser/neurolink.min.js +386 -386
  4. package/dist/cli/factories/commandFactory.js +26 -3
  5. package/dist/constants/enums.d.ts +10 -0
  6. package/dist/constants/enums.js +11 -0
  7. package/dist/factories/providerDescriptors.d.ts +1 -1
  8. package/dist/factories/providerDescriptors.js +15 -13
  9. package/dist/image-gen/ImageGenService.js +16 -14
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.js +1 -0
  12. package/dist/mcp/externalServerManager.d.ts +9 -0
  13. package/dist/mcp/externalServerManager.js +88 -16
  14. package/dist/neurolink.js +18 -11
  15. package/dist/providers/amazonBedrock/client.js +30 -1
  16. package/dist/providers/catalog/friendli.json +130 -0
  17. package/dist/providers/catalog/index.generated.d.ts +1 -1
  18. package/dist/providers/catalog/index.generated.js +3 -0
  19. package/dist/providers/catalog/mistral.json +1 -2
  20. package/dist/providers/catalog/schema.js +34 -0
  21. package/dist/providers/googleAiStudio/client.js +18 -2
  22. package/dist/providers/googleVertex/client.js +13 -4
  23. package/dist/providers/recraft.js +8 -1
  24. package/dist/proxy/accountRanking.d.ts +22 -0
  25. package/dist/proxy/accountRanking.js +86 -0
  26. package/dist/proxy/proxyConfig.js +78 -17
  27. package/dist/server/routes/claudeProxyRoutes.d.ts +12 -4
  28. package/dist/server/routes/claudeProxyRoutes.js +54 -84
  29. package/dist/types/cli.d.ts +2 -0
  30. package/dist/types/externalMcp.d.ts +20 -2
  31. package/dist/types/generate.d.ts +6 -1
  32. package/dist/types/mcp.d.ts +17 -0
  33. package/dist/types/providerCatalog.generated.d.ts +2 -2
  34. package/dist/types/providers.d.ts +4 -0
  35. package/dist/types/proxy.d.ts +3 -0
  36. package/dist/utils/imageDetection.d.ts +10 -0
  37. package/dist/utils/imageDetection.js +38 -12
  38. package/docs-site/static/search-index.json +1 -1
  39. package/package.json +3 -2
@@ -0,0 +1,130 @@
1
+ {
2
+ "$schema": "./provider-catalog.schema.json",
3
+ "id": "friendli",
4
+ "displayName": "FriendliAI",
5
+ "aliases": [],
6
+ "tier": 2,
7
+ "wire": {
8
+ "baseURL": "https://api.friendli.ai/serverless/v1"
9
+ },
10
+ "models": {
11
+ "default": "zai-org/GLM-5.3",
12
+ "fallbacks": [
13
+ "zai-org/GLM-5.3",
14
+ "zai-org/GLM-5.3-Flash",
15
+ "zai-org/GLM-5.2",
16
+ "zai-org/GLM-5.1",
17
+ "google/gemma-4-31B-it",
18
+ "deepseek-ai/DeepSeek-V3.2",
19
+ "MiniMaxAI/MiniMax-M2.5"
20
+ ],
21
+ "defaultContextWindow": 1048576,
22
+ "defaultMaxOutputTokens": 131072,
23
+ "catalog": {
24
+ "zai-org/GLM-5.3": {
25
+ "contextWindow": 1048576,
26
+ "maxOutputTokens": 131072,
27
+ "pricingPerMTok": {
28
+ "input": 1.4,
29
+ "output": 4.4,
30
+ "cachedInput": 0.26
31
+ },
32
+ "vision": false,
33
+ "status": "production",
34
+ "description": "Recommended - Zhipu's flagship GLM-5.3, the only model in this roster that was live capability-probed end to end: a real structured tool call (finish_reason=tool_calls, 9 tool_calls deltas under streaming), SSE streaming (62 events, terminal [DONE]), and json_object structured output. A heavy internal reasoner exposed via reasoning/reasoning_content — at a small max_tokens budget the whole completion is spent on unfinished reasoning before any visible content, so give it a generous max_tokens. Rejects image/video input and rejects tools+response_format together, both HTTP 422 (see capabilities.structuredOutputWithTools). maxOutputTokens is the documented per-request output ceiling from https://friendli.ai/docs/examples/models/zai-glm-5-3/overview.md (retrieved 2026-09-09) — 131,072 tokens, distinct from the 1,048,576-token context window; the authenticated roster's max_completion_tokens field reports 1048576 (equal to context) but that is not treated as the output cap. Pricing per the same page: $1.40 / $4.40 per MTok input/output, $0.26 per MTok cached input."
35
+ },
36
+ "zai-org/GLM-5.3-Flash": {
37
+ "vision": false,
38
+ "status": "production",
39
+ "description": "Zhipu GLM-5.3 Flash — lighter/faster sibling of the default; present on the live roster (GET /serverless/v1/models, 200) but not itself capability-probed under the tight rate limit; fallback."
40
+ },
41
+ "zai-org/GLM-5.2": {
42
+ "vision": false,
43
+ "status": "production",
44
+ "description": "Zhipu GLM-5.2 — prior GLM generation; roster-listed only, not capability-probed; fallback."
45
+ },
46
+ "zai-org/GLM-5.1": {
47
+ "vision": false,
48
+ "status": "production",
49
+ "description": "Zhipu GLM-5.1 — prior GLM generation; roster-listed only, not capability-probed; fallback."
50
+ },
51
+ "google/gemma-4-31B-it": {
52
+ "vision": false,
53
+ "status": "production",
54
+ "description": "Google Gemma 4 31B instruct-tuned; roster-listed only, not capability-probed; fallback."
55
+ },
56
+ "deepseek-ai/DeepSeek-V3.2": {
57
+ "vision": false,
58
+ "status": "production",
59
+ "description": "DeepSeek V3.2; roster-listed only, not capability-probed; fallback."
60
+ },
61
+ "MiniMaxAI/MiniMax-M2.5": {
62
+ "vision": false,
63
+ "status": "production",
64
+ "description": "MiniMax M2.5; roster-listed only, not capability-probed; fallback."
65
+ }
66
+ },
67
+ "topModels": [
68
+ "zai-org/GLM-5.3",
69
+ "zai-org/GLM-5.3-Flash",
70
+ "zai-org/GLM-5.2",
71
+ "zai-org/GLM-5.1",
72
+ "google/gemma-4-31B-it",
73
+ "deepseek-ai/DeepSeek-V3.2",
74
+ "MiniMaxAI/MiniMax-M2.5"
75
+ ]
76
+ },
77
+ "capabilities": {
78
+ "text": true,
79
+ "streaming": true,
80
+ "tools": true,
81
+ "toolsWithStreaming": true,
82
+ "structuredOutput": true,
83
+ "structuredOutputWithTools": false,
84
+ "embeddings": false,
85
+ "thinking": false
86
+ },
87
+ "errorRules": [
88
+ {
89
+ "status": 401,
90
+ "pattern": "Unauthorized|valid credential",
91
+ "class": "authentication",
92
+ "message": "Invalid Friendli API key. Check {apiKeyEnvVar}. Get one at {setupUrl}"
93
+ },
94
+ {
95
+ "status": 404,
96
+ "pattern": "\\bModel\\s+['\"][^'\"\\r\\n]+['\"]\\s+not\\s+found\\b",
97
+ "class": "invalid-model",
98
+ "message": "Friendli model '{model}' not found. Pick a current model from the authenticated /serverless/v1/models roster or {setupUrl}"
99
+ }
100
+ ],
101
+ "setup": {
102
+ "url": "https://suite.friendli.ai/",
103
+ "apiKeyFormat": null,
104
+ "billingPolicy": "free-tier",
105
+ "instructions": [
106
+ "1. Visit: https://suite.friendli.ai/ and sign in",
107
+ "2. Create a Personal Access Token for the serverless endpoints",
108
+ "3. New accounts start with trial credit; confirm current billing terms in the console before heavy use",
109
+ "4. Set {apiKeyEnvVar} in your .env file"
110
+ ],
111
+ "description": "OpenAI-compatible endpoint at https://api.friendli.ai/serverless/v1, serving a small zai-org GLM-family roster plus a few third-party models. Rate limits are tight — pace requests at least 20 seconds apart."
112
+ },
113
+ "evidence": {
114
+ "rosterVerified": {
115
+ "date": "2026-09-06",
116
+ "method": "authenticated GET /serverless/v1/models",
117
+ "status": 200
118
+ },
119
+ "authProbe": {
120
+ "date": "2026-09-06",
121
+ "method": "POST /chat/completions with an invalid API key",
122
+ "status": 401
123
+ },
124
+ "liveMatrix": {
125
+ "date": "2026-09-06",
126
+ "result": "Probed against the default model, zai-org/GLM-5.3, via https://api.friendli.ai/serverless/v1. The first probe run used a 2s cadence, tripped Friendli's rate limit (12/16 cases 429) and is discarded entirely; this result is from a slower-paced re-run plus targeted single-call re-probes, each still hitting 429 occasionally even at ~25s spacing — Friendli's rate limits are tight, so any future live check needs generous pacing (20s+) and retry. Roster: GET /serverless/v1/models returns 200 with exactly 7 ids, all zai-org GLM family plus google/gemma-4-31B-it, deepseek-ai/DeepSeek-V3.2 and MiniMaxAI/MiniMax-M2.5 — models.fallbacks lists all 7. Wire mechanics on GLM-5.3: chat, system role, array content parts (message content IS accepted as a parts array, so no messageContentFormat quirk is declared), sampling params and max_completion_tokens all 200. Streaming: SSE, 200, 62 events, terminal [DONE] present — contentDeltas were 0 only because GLM-5.3 is a heavy reasoner that spent the small test budget entirely on reasoning/reasoning_content fields, not because streaming is broken. Tool calling: a real structured tool_call (get_time, tz argument parsed, finish_reason=tool_calls) on the first turn, a null-content assistant tool_calls turn accepted on replay, and 9 tool_calls deltas surfacing mid-SSE-stream — tools and toolsWithStreaming true; tool_choice is tolerated even with no tools declared. response_format json_object returns 200 cleanly — structuredOutput true. response_format json_schema also returns 200, but at the tested token budget the content is unfinished reasoning text rather than schema-conformant JSON — same reasoning-budget effect as above; NeuroLink's coerceJsonToSchema repair layer is the intended safety net. Sending tools and a JSON schema together in the same request is a genuine, hard rejection: HTTP 422 'The 'response_format' field cannot be set when tools are specified' — the same class of vendor exclusivity NeuroLink already handles generically for Groq via isToolsSchemaConflictError (pattern-matches the response_format+tool wording in the thrown error and retries the call without response_format), so no provider-specific quirk was added — structuredOutputWithTools is declared false. Vision: a real image part returns HTTP 422 'This model does not support image/video inputs.' — genuinely unsupported, not a rate limit; no model in this roster is marked vision:true and models.visionModel is left unset. Error shapes: bad key -> 401 {\"detail\":\"Unauthorized. Please provide a valid credential.\"}; bad model -> 404 {\"detail\":\"Model '<id>' not found.\"}. Only zai-org/GLM-5.3 (the declared default) was capability-probed end to end under the account's tight rate limit; the other 6 roster ids are roster-verified by id only, matching the account's usable request budget for this pass."
127
+ },
128
+ "addedInPR": "https://github.com/juspay/neurolink/pull/1657"
129
+ }
130
+ }
@@ -1,3 +1,3 @@
1
1
  import type { ProviderCatalogJson } from "../../types/index.js";
2
2
  export declare const CATALOG_JSON_ENTRIES: ProviderCatalogJson[];
3
- export declare const CATALOG_PROVIDER_IDS: readonly ["api-route", "baseten", "cerebras", "cloudflare", "fireworks", "gmicloud", "groq", "inception-labs", "io-intelligence", "mancer", "mistral", "perplexity", "sambanova", "together-ai", "upstage", "xai"];
3
+ export declare const CATALOG_PROVIDER_IDS: readonly ["api-route", "baseten", "cerebras", "cloudflare", "fireworks", "friendli", "gmicloud", "groq", "inception-labs", "io-intelligence", "mancer", "mistral", "perplexity", "sambanova", "together-ai", "upstage", "xai"];
@@ -5,6 +5,7 @@ import basetenJson from "./baseten.json" with { type: "json" };
5
5
  import cerebrasJson from "./cerebras.json" with { type: "json" };
6
6
  import cloudflareJson from "./cloudflare.json" with { type: "json" };
7
7
  import fireworksJson from "./fireworks.json" with { type: "json" };
8
+ import friendliJson from "./friendli.json" with { type: "json" };
8
9
  import gmicloudJson from "./gmicloud.json" with { type: "json" };
9
10
  import groqJson from "./groq.json" with { type: "json" };
10
11
  import inceptionLabsJson from "./inception-labs.json" with { type: "json" };
@@ -22,6 +23,7 @@ export const CATALOG_JSON_ENTRIES = [
22
23
  cerebrasJson,
23
24
  cloudflareJson,
24
25
  fireworksJson,
26
+ friendliJson,
25
27
  gmicloudJson,
26
28
  groqJson,
27
29
  inceptionLabsJson,
@@ -40,6 +42,7 @@ export const CATALOG_PROVIDER_IDS = [
40
42
  "cerebras",
41
43
  "cloudflare",
42
44
  "fireworks",
45
+ "friendli",
43
46
  "gmicloud",
44
47
  "groq",
45
48
  "inception-labs",
@@ -9,9 +9,8 @@
9
9
  },
10
10
  "models": {
11
11
  "default": "mistral-small-2506",
12
- "fallbackModelName": "mistral-small-2506",
13
12
  "registryDefaultModel": "mistral-large-latest",
14
- "fallbacks": ["mistral-small-2506", "mistral-large-latest"],
13
+ "fallbacks": ["mistral-small-2506", "mistral-medium-latest"],
15
14
  "defaultContextWindow": 128000,
16
15
  "defaultMaxOutputTokens": 4096,
17
16
  "catalog": {
@@ -230,6 +230,40 @@ const catalogModelsSchema = z
230
230
  message: "models.fallbackModelName must be a key of models.catalog",
231
231
  });
232
232
  }
233
+ // A fallback that resolves to the default model cannot fall back.
234
+ //
235
+ // `loader.ts` derives it positionally as
236
+ // `fallbackModelName ?? fallbacks[1] ?? fallbacks[0]`, and nothing checked
237
+ // the result. A provider with one model has no choice and is left alone;
238
+ // a provider with alternatives that still lands on its own default has
239
+ // silently disabled its retry path — the primary fails, the SDK retries
240
+ // the same model, and it fails again for the same reason.
241
+ //
242
+ // This is not hypothetical. `mistral.json` ships 32 models and explicitly
243
+ // sets `fallbackModelName` to its own default, overriding a perfectly good
244
+ // `fallbacks[1]`. Two single-model catalogs also resolve this way and are
245
+ // correctly exempt.
246
+ const derivedFallback = models.fallbackModelName ?? models.fallbacks[1] ?? models.fallbacks[0];
247
+ // Count only entries that could actually serve as a fallback. The whole
248
+ // `catalog` map also carries retired ids, so its size both over-reports in
249
+ // the message below and could fire this rule on a catalog whose only other
250
+ // entries are unusable — the mirror of the single-model exemption.
251
+ const selectableModels = Object.values(models.catalog).filter((spec) => spec.status !== "retired").length;
252
+ if (derivedFallback !== undefined &&
253
+ derivedFallback === models.default &&
254
+ selectableModels > 1) {
255
+ ctx.addIssue({
256
+ code: "custom",
257
+ path: models.fallbackModelName !== undefined
258
+ ? ["fallbackModelName"]
259
+ : ["fallbacks"],
260
+ message: `the fallback model resolves to "${derivedFallback}", which is also models.default — ` +
261
+ `a fallback identical to the primary cannot fall back. This catalog declares ` +
262
+ `${selectableModels} selectable models, so name a different one (or drop ` +
263
+ `fallbackModelName to ` +
264
+ `let fallbacks[1] apply). Single-model catalogs are exempt.`,
265
+ });
266
+ }
233
267
  if (models.registryDefaultModel !== undefined &&
234
268
  !catalogKeys.has(models.registryDefaultModel)) {
235
269
  ctx.addIssue({
@@ -380,7 +380,13 @@ export class GoogleAIStudioProvider extends BaseProvider {
380
380
  if ("inlineData" in part && part.inlineData?.data) {
381
381
  const foundImageData = part.inlineData.data;
382
382
  imageData = foundImageData;
383
- const mimeType = part.inlineData.mimeType || "image/png";
383
+ // Keep the DECLARED value separate from the display default.
384
+ // ImageGenService trusts a string `mimeType` on imageOutput and
385
+ // skips sniffing, so forwarding a hardcoded "image/png" for a
386
+ // vendor that declared nothing would defeat the byte detection
387
+ // — WebP bytes would ship labelled PNG, with a .png extension.
388
+ const declaredMimeType = part.inlineData.mimeType;
389
+ const mimeType = declaredMimeType || "image/png";
384
390
  logger.info("Image generation successful", {
385
391
  model: imageModelName,
386
392
  mimeType,
@@ -390,7 +396,12 @@ export class GoogleAIStudioProvider extends BaseProvider {
390
396
  const result = {
391
397
  content: `Generated image using ${imageModelName} (${mimeType})`,
392
398
  imageOutput: {
399
+ // The vendor declares the format on the inline part; that
400
+ // beats sniffing it back out of the bytes. When it declares
401
+ // nothing, omit the field entirely so the service sniffs
402
+ // rather than trusting a default we invented.
393
403
  base64: foundImageData,
404
+ ...(declaredMimeType ? { mimeType: declaredMimeType } : {}),
394
405
  },
395
406
  provider: this.providerName,
396
407
  model: imageModelName,
@@ -434,7 +445,10 @@ export class GoogleAIStudioProvider extends BaseProvider {
434
445
  if ("inlineData" in part && part.inlineData?.data) {
435
446
  const foundImageData = part.inlineData.data;
436
447
  imageData = foundImageData;
437
- const mimeType = part.inlineData.mimeType || "image/png";
448
+ // See the streaming site above: only a mime type the vendor
449
+ // actually declared may be forwarded, or the sniffer is bypassed.
450
+ const declaredMimeType = part.inlineData.mimeType;
451
+ const mimeType = declaredMimeType || "image/png";
438
452
  logger.info("Image generation successful (non-streaming)", {
439
453
  model: imageModelName,
440
454
  mimeType,
@@ -444,7 +458,9 @@ export class GoogleAIStudioProvider extends BaseProvider {
444
458
  const result = {
445
459
  content: `Generated image using ${imageModelName} (${mimeType})`,
446
460
  imageOutput: {
461
+ // As above: forward only a declared value, never a default.
447
462
  base64: foundImageData,
463
+ ...(declaredMimeType ? { mimeType: declaredMimeType } : {}),
448
464
  },
449
465
  provider: this.providerName,
450
466
  model: imageModelName,
@@ -5229,7 +5229,12 @@ export class GoogleVertexProvider extends BaseProvider {
5229
5229
  if (imageOutput) {
5230
5230
  yield {
5231
5231
  type: "image",
5232
- imageOutput: { base64: imageOutput.base64 || "" },
5232
+ imageOutput: {
5233
+ base64: imageOutput.base64 || "",
5234
+ ...(imageOutput.mimeType
5235
+ ? { mimeType: imageOutput.mimeType }
5236
+ : {}),
5237
+ },
5233
5238
  };
5234
5239
  }
5235
5240
  yield { content: textContent };
@@ -6370,9 +6375,12 @@ export class GoogleVertexProvider extends BaseProvider {
6370
6375
  }
6371
6376
  // Extract image data (handle both formats)
6372
6377
  const imageData = imagePart.inlineData?.data || imagePart.inline_data?.data;
6373
- const mimeType = imagePart.inlineData?.mimeType ||
6374
- imagePart.inline_data?.mime_type ||
6375
- "image/png";
6378
+ // The declared label and the display default are deliberately separate.
6379
+ // `ImageGenService` trusts a string `mimeType` and skips sniffing, so
6380
+ // stamping the `image/png` default onto `imageOutput` would relabel an
6381
+ // unlabelled WebP as PNG — the exact defect this change fixes elsewhere.
6382
+ const declaredMimeType = imagePart.inlineData?.mimeType || imagePart.inline_data?.mime_type;
6383
+ const mimeType = declaredMimeType || "image/png";
6376
6384
  if (!imageData) {
6377
6385
  throw new ProviderError("Image part found but no data available", this.providerName);
6378
6386
  }
@@ -6387,6 +6395,7 @@ export class GoogleVertexProvider extends BaseProvider {
6387
6395
  content: `Generated image using ${imageModelName} (${mimeType})`,
6388
6396
  imageOutput: {
6389
6397
  base64: imageData,
6398
+ ...(declaredMimeType ? { mimeType: declaredMimeType } : {}),
6390
6399
  },
6391
6400
  provider: this.providerName,
6392
6401
  model: imageModelName,
@@ -7,6 +7,7 @@ import { logger } from "../utils/logger.js";
7
7
  import { createRecraftConfig, getProviderModel, validateApiKey, } from "../utils/providerConfig.js";
8
8
  import { MAX_IMAGE_BYTES, readBoundedBuffer } from "../utils/sizeGuard.js";
9
9
  import { assertSafeUrl } from "../utils/ssrfGuard.js";
10
+ import { detectImageMimeType } from "../utils/imageDetection.js";
10
11
  const RECRAFT_DEFAULT_BASE_URL = "https://external.api.recraft.ai/v1";
11
12
  const REQUEST_TIMEOUT_MS = 120_000;
12
13
  const getRecraftApiKey = () => validateApiKey(createRecraftConfig());
@@ -178,7 +179,13 @@ export class RecraftProvider extends BaseProvider {
178
179
  model: this.modelName,
179
180
  // output: 1000 = sentinel for per-image pricing (see pricing.ts)
180
181
  usage: { input: 0, output: 1000, total: 1000 },
181
- imageOutput: { base64 },
182
+ // Recraft answers b64_json with WebP bytes for V3 raster models; label
183
+ // the real format from the leading bytes instead of leaving callers to
184
+ // assume PNG.
185
+ imageOutput: {
186
+ base64,
187
+ mimeType: detectImageMimeType(Buffer.from(base64.slice(0, 64), "base64")),
188
+ },
182
189
  };
183
190
  }
184
191
  async validateConfiguration() {
@@ -0,0 +1,22 @@
1
+ import type { ProxyAccountRoutingReason, ProxyAccountSortMetrics, ProxyPassthroughAccount } from "../types/index.js";
2
+ /**
3
+ * The pre-existing "spend the soonest-expiring weekly allowance first" order.
4
+ * Moved verbatim from claudeProxyRoutes.ts — availability → quota-evidence
5
+ * quality → session saturation → model-scoped saturation → soonest weekly
6
+ * reset → soonest session-reset bucket → model-scoped utilization → highest
7
+ * weekly utilization → configured primary → insertion order.
8
+ */
9
+ export declare function compareExpiryFirst(a: ProxyPassthroughAccount, b: ProxyPassthroughAccount, metricsByKey: ReadonlyMap<string, ProxyAccountSortMetrics>, primaryKey: string | undefined): [number, ProxyAccountRoutingReason];
10
+ /**
11
+ * Orders accounts by the configured ranking and reports the reason that
12
+ * placed rank 0. PR1 ships only `expiry-first`; PR2 (Task 11) adds
13
+ * `headroom-first` plus the affinity/prefer-primary precedence layer.
14
+ */
15
+ export declare function rankAccounts(args: {
16
+ accounts: ProxyPassthroughAccount[];
17
+ metricsByKey: ReadonlyMap<string, ProxyAccountSortMetrics>;
18
+ primaryKey: string | undefined;
19
+ }): {
20
+ orderedAccounts: ProxyPassthroughAccount[];
21
+ reason: ProxyAccountRoutingReason;
22
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The pre-existing "spend the soonest-expiring weekly allowance first" order.
3
+ * Moved verbatim from claudeProxyRoutes.ts — availability → quota-evidence
4
+ * quality → session saturation → model-scoped saturation → soonest weekly
5
+ * reset → soonest session-reset bucket → model-scoped utilization → highest
6
+ * weekly utilization → configured primary → insertion order.
7
+ */
8
+ export function compareExpiryFirst(a, b, metricsByKey, primaryKey) {
9
+ const ma = metricsByKey.get(a.key);
10
+ const mb = metricsByKey.get(b.key);
11
+ if (!ma || !mb) {
12
+ return [0, "insertion_order"];
13
+ }
14
+ if (ma.usable !== mb.usable) {
15
+ return [ma.usable ? -1 : 1, "availability"];
16
+ }
17
+ if (!ma.usable && !mb.usable) {
18
+ const au = ma.coolingUntil || Number.POSITIVE_INFINITY;
19
+ const bu = mb.coolingUntil || Number.POSITIVE_INFINITY;
20
+ return [
21
+ au === bu ? 0 : au - bu,
22
+ au === bu ? "insertion_order" : "cooldown_recovery",
23
+ ];
24
+ }
25
+ if (ma.quotaEvidenceRank !== mb.quotaEvidenceRank) {
26
+ return [ma.quotaEvidenceRank - mb.quotaEvidenceRank, "quota_evidence"];
27
+ }
28
+ if (ma.saturated !== mb.saturated) {
29
+ return [ma.saturated ? 1 : -1, "session_headroom"];
30
+ }
31
+ // Per-model headroom, after overall session capacity: an account whose cap
32
+ // for THIS model is nearly spent is demoted even when its 5h/7d are healthy.
33
+ // No-op when neither account reports a scoped window for the model.
34
+ if (ma.scopedSaturated !== mb.scopedSaturated) {
35
+ return [ma.scopedSaturated ? 1 : -1, "scoped_headroom"];
36
+ }
37
+ if (ma.saturated && mb.saturated) {
38
+ if (ma.sessionResetBucket !== mb.sessionResetBucket) {
39
+ return [ma.sessionResetBucket - mb.sessionResetBucket, "session_reset"];
40
+ }
41
+ if (ma.weeklyReset !== mb.weeklyReset) {
42
+ return [ma.weeklyReset - mb.weeklyReset, "weekly_reset"];
43
+ }
44
+ }
45
+ else {
46
+ if (ma.weeklyReset !== mb.weeklyReset) {
47
+ return [ma.weeklyReset - mb.weeklyReset, "weekly_reset"];
48
+ }
49
+ if (ma.sessionResetBucket !== mb.sessionResetBucket) {
50
+ return [ma.sessionResetBucket - mb.sessionResetBucket, "session_reset"];
51
+ }
52
+ }
53
+ // Fill-first within the per-model allowance: finish off the account closest
54
+ // to spending its cap for this model before opening a fresher one. Ranked
55
+ // above overall weekly utilization because it is the tighter constraint.
56
+ // Both sides must actually report a scoped window. Comparing a real
57
+ // utilization against the "absent" sentinel would rank the account that has a
58
+ // window above one that does not — and since only the account serving a model
59
+ // gets that model's window, it would funnel all of a model's traffic onto
60
+ // whichever account happened to serve it first.
61
+ if (ma.scopedUsed !== null &&
62
+ mb.scopedUsed !== null &&
63
+ ma.scopedUsedForSort !== mb.scopedUsedForSort) {
64
+ return [mb.scopedUsedForSort - ma.scopedUsedForSort, "scoped_utilization"];
65
+ }
66
+ if (ma.weeklyUsedForSort !== mb.weeklyUsedForSort) {
67
+ return [mb.weeklyUsedForSort - ma.weeklyUsedForSort, "weekly_utilization"];
68
+ }
69
+ if (primaryKey && (a.key === primaryKey) !== (b.key === primaryKey)) {
70
+ return [a.key === primaryKey ? -1 : 1, "configured_primary"];
71
+ }
72
+ return [0, "insertion_order"];
73
+ }
74
+ /**
75
+ * Orders accounts by the configured ranking and reports the reason that
76
+ * placed rank 0. PR1 ships only `expiry-first`; PR2 (Task 11) adds
77
+ * `headroom-first` plus the affinity/prefer-primary precedence layer.
78
+ */
79
+ export function rankAccounts(args) {
80
+ const { accounts, metricsByKey, primaryKey } = args;
81
+ const orderedAccounts = [...accounts].sort((a, b) => compareExpiryFirst(a, b, metricsByKey, primaryKey)[0]);
82
+ const reason = orderedAccounts.length < 2
83
+ ? "single_account"
84
+ : compareExpiryFirst(orderedAccounts[0], orderedAccounts[1], metricsByKey, primaryKey)[1];
85
+ return { orderedAccounts, reason };
86
+ }