@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.
- package/CHANGELOG.md +2 -2
- package/dist/adapters/providerImageAdapter.js +16 -11
- package/dist/browser/neurolink.min.js +386 -386
- package/dist/cli/factories/commandFactory.js +26 -3
- package/dist/constants/enums.d.ts +10 -0
- package/dist/constants/enums.js +11 -0
- package/dist/factories/providerDescriptors.d.ts +1 -1
- package/dist/factories/providerDescriptors.js +15 -13
- package/dist/image-gen/ImageGenService.js +16 -14
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/externalServerManager.d.ts +9 -0
- package/dist/mcp/externalServerManager.js +88 -16
- package/dist/neurolink.js +18 -11
- package/dist/providers/amazonBedrock/client.js +30 -1
- package/dist/providers/catalog/friendli.json +130 -0
- package/dist/providers/catalog/index.generated.d.ts +1 -1
- package/dist/providers/catalog/index.generated.js +3 -0
- package/dist/providers/catalog/mistral.json +1 -2
- package/dist/providers/catalog/schema.js +34 -0
- package/dist/providers/googleAiStudio/client.js +18 -2
- package/dist/providers/googleVertex/client.js +13 -4
- package/dist/providers/recraft.js +8 -1
- package/dist/proxy/accountRanking.d.ts +22 -0
- package/dist/proxy/accountRanking.js +86 -0
- package/dist/proxy/proxyConfig.js +78 -17
- package/dist/server/routes/claudeProxyRoutes.d.ts +12 -4
- package/dist/server/routes/claudeProxyRoutes.js +54 -84
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/externalMcp.d.ts +20 -2
- package/dist/types/generate.d.ts +6 -1
- package/dist/types/mcp.d.ts +17 -0
- package/dist/types/providerCatalog.generated.d.ts +2 -2
- package/dist/types/providers.d.ts +4 -0
- package/dist/types/proxy.d.ts +3 -0
- package/dist/utils/imageDetection.d.ts +10 -0
- package/dist/utils/imageDetection.js +38 -12
- package/docs-site/static/search-index.json +1 -1
- 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-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
6374
|
-
|
|
6375
|
-
|
|
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
|
-
|
|
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
|
+
}
|