@juspay/neurolink 9.79.1 → 9.79.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +337 -332
  3. package/dist/core/modules/GenerationHandler.js +43 -1
  4. package/dist/core/modules/structuredOutputPolicy.d.ts +42 -6
  5. package/dist/core/modules/structuredOutputPolicy.js +58 -7
  6. package/dist/lib/core/modules/GenerationHandler.js +43 -1
  7. package/dist/lib/core/modules/structuredOutputPolicy.d.ts +42 -6
  8. package/dist/lib/core/modules/structuredOutputPolicy.js +58 -7
  9. package/dist/lib/processors/document/ExcelProcessor.js +9 -1
  10. package/dist/lib/providers/anthropic.js +37 -41
  11. package/dist/lib/providers/anthropicImageBlocks.d.ts +47 -0
  12. package/dist/lib/providers/anthropicImageBlocks.js +223 -0
  13. package/dist/lib/providers/googleNativeGemini3.d.ts +26 -0
  14. package/dist/lib/providers/googleNativeGemini3.js +48 -0
  15. package/dist/lib/providers/googleVertex.d.ts +16 -0
  16. package/dist/lib/providers/googleVertex.js +200 -24
  17. package/dist/lib/proxy/oauthFetch.js +26 -14
  18. package/dist/lib/proxy/proxyTracer.d.ts +7 -1
  19. package/dist/lib/proxy/proxyTracer.js +29 -0
  20. package/dist/lib/proxy/systemRelocation.d.ts +21 -0
  21. package/dist/lib/proxy/systemRelocation.js +51 -0
  22. package/dist/lib/server/routes/claudeProxyRoutes.js +147 -19
  23. package/dist/lib/types/proxy.d.ts +10 -0
  24. package/dist/processors/document/ExcelProcessor.js +9 -1
  25. package/dist/providers/anthropic.js +37 -41
  26. package/dist/providers/anthropicImageBlocks.d.ts +47 -0
  27. package/dist/providers/anthropicImageBlocks.js +222 -0
  28. package/dist/providers/googleNativeGemini3.d.ts +26 -0
  29. package/dist/providers/googleNativeGemini3.js +48 -0
  30. package/dist/providers/googleVertex.d.ts +16 -0
  31. package/dist/providers/googleVertex.js +200 -24
  32. package/dist/proxy/oauthFetch.js +26 -14
  33. package/dist/proxy/proxyTracer.d.ts +7 -1
  34. package/dist/proxy/proxyTracer.js +29 -0
  35. package/dist/proxy/systemRelocation.d.ts +21 -0
  36. package/dist/proxy/systemRelocation.js +50 -0
  37. package/dist/server/routes/claudeProxyRoutes.js +147 -19
  38. package/dist/types/proxy.d.ts +10 -0
  39. package/package.json +8 -2
@@ -20,6 +20,7 @@ import { tracers } from "../../telemetry/tracers.js";
20
20
  import { withSpan } from "../../telemetry/withSpan.js";
21
21
  import { ProxyTracer, recordFallbackAttempt } from "../../proxy/proxyTracer.js";
22
22
  import { createRawStreamCapture } from "../../proxy/rawStreamCapture.js";
23
+ import { relocateClientSystemIntoMessages } from "../../proxy/systemRelocation.js";
23
24
  import { logBodyCapture, logRequest, logRequestAttempt, logStreamError, } from "../../proxy/requestLogger.js";
24
25
  import { createSSEInterceptor } from "../../proxy/sseInterceptor.js";
25
26
  import { needsRefresh, persistTokens, refreshToken, } from "../../proxy/tokenRefresh.js";
@@ -332,6 +333,63 @@ async function maybeRefreshClaudeSnapshot(accountLabel, accountKey, headers, bod
332
333
  }
333
334
  return next;
334
335
  }
336
+ /**
337
+ * Parse response-side details (model, finish reason, invoked tools) from a
338
+ * non-streaming Anthropic reply so they can be recorded on the trace span.
339
+ */
340
+ function extractResponseInfo(responseJson) {
341
+ const info = {};
342
+ if (!responseJson || typeof responseJson !== "object") {
343
+ return info;
344
+ }
345
+ const r = responseJson;
346
+ if (typeof r.model === "string") {
347
+ info.responseModel = r.model;
348
+ }
349
+ if (typeof r.stop_reason === "string") {
350
+ info.finishReason = r.stop_reason;
351
+ }
352
+ if (typeof r.stop_sequence === "string") {
353
+ info.stopSequence = r.stop_sequence;
354
+ }
355
+ if (Array.isArray(r.content)) {
356
+ const toolCalls = r.content
357
+ .filter((b) => !!b &&
358
+ typeof b === "object" &&
359
+ b.type === "tool_use")
360
+ .map((b) => String(b.name ?? ""))
361
+ .filter((n) => n.length > 0);
362
+ if (toolCalls.length > 0) {
363
+ info.toolCalls = toolCalls;
364
+ }
365
+ }
366
+ return info;
367
+ }
368
+ /**
369
+ * Build response-info from streaming telemetry (responding model, finish
370
+ * reason, and the tools the model invoked via tool_use content blocks) so the
371
+ * streaming path records the same gen_ai.response.* attributes as non-streaming.
372
+ */
373
+ function responseInfoFromStream(data) {
374
+ const info = {};
375
+ if (data.model) {
376
+ info.responseModel = data.model;
377
+ }
378
+ if (data.stopReason) {
379
+ info.finishReason = data.stopReason;
380
+ }
381
+ if (data.stopSequence) {
382
+ info.stopSequence = data.stopSequence;
383
+ }
384
+ const toolCalls = (data.contentBlocks ?? [])
385
+ .filter((b) => b.type === "tool_use")
386
+ .map((b) => String(b.toolName ?? ""))
387
+ .filter((n) => n.length > 0);
388
+ if (toolCalls.length > 0) {
389
+ info.toolCalls = toolCalls;
390
+ }
391
+ return info;
392
+ }
335
393
  /**
336
394
  * Polyfill the request body for OAuth accounts.
337
395
  * Claude Code injects a billing header, agent block, and metadata.user_id
@@ -350,25 +408,21 @@ function polyfillOAuthBody(bodyStr, accountToken, snapshot, preferredSessionId)
350
408
  text: snapshot?.body?.agentBlock ||
351
409
  "You are a Claude agent, built on Anthropic's Claude Agent SDK.",
352
410
  };
353
- // Normalise system to array and APPEND billing + agent blocks.
354
- // IMPORTANT: We append (not prepend) to preserve the client's cache
355
- // prefix chain. Anthropic's prompt caching uses prefix matching — if we
356
- // insert anything before the client's system blocks, we invalidate all
357
- // cached content (tools, system prompt, message history).
411
+ // Normalise system to an array, then route by client type.
358
412
  //
359
- // Claude Code sends a billing block with a `cch=<hash>` value that changes
360
- // on every request. We fix this by:
361
- // 1. Removing the client's billing block from its current position
362
- // 2. Stabilizing it while keeping the official Claude Code shape
363
- // 3. Appending it at the END so the cacheable system blocks stay
364
- // at the front of the prefix chain
413
+ // The subscription/OAuth path only accepts a `system` it recognises as the
414
+ // genuine Claude Code prompt. A real CC client sends its own billing + agent
415
+ // identity blocks alongside the canonical prompt we keep those in place and
416
+ // just stabilise the volatile billing `cch`. A custom client (Curator) sends
417
+ // its own arbitrary system prompt with NO agent block; left in `system` it is
418
+ // rejected as `rate_limit_error: "Error"`, so we relocate it into the message
419
+ // stream and send only the recognised billing + agent blocks as `system`.
365
420
  if (parsed.system) {
366
421
  if (typeof parsed.system === "string") {
367
422
  parsed.system = [{ type: "text", text: parsed.system }];
368
423
  }
369
424
  if (Array.isArray(parsed.system)) {
370
- // Find and remove existing billing/agent blocks from wherever
371
- // the client placed them (typically at system[0])
425
+ // Find existing billing/agent blocks wherever the client placed them.
372
426
  const billingIdx = parsed.system.findIndex((b) => typeof b.text === "string" &&
373
427
  b.text.includes("x-anthropic-billing-header"));
374
428
  const agentIdx = parsed.system.findIndex((b) => typeof b.text === "string" && b.text.includes("Claude Agent SDK"));
@@ -376,17 +430,29 @@ function polyfillOAuthBody(bodyStr, accountToken, snapshot, preferredSessionId)
376
430
  type: "text",
377
431
  text: buildStableClaudeCodeBillingHeader(parsed.system[billingIdx]?.text ?? snapshot?.body?.billingHeader),
378
432
  };
379
- // Remove in reverse index order so indices stay valid
433
+ // A genuine Claude Code client supplies its own agent-identity block;
434
+ // a custom client (Curator) does not.
435
+ const isClaudeCodeClient = agentIdx >= 0;
436
+ // Strip billing/agent from their positions (reverse order so indices
437
+ // stay valid). What remains is the client's "extra" system content.
380
438
  const indicesToRemove = [billingIdx, agentIdx]
381
439
  .filter((i) => i >= 0)
382
440
  .sort((a, b) => b - a);
383
441
  for (const idx of indicesToRemove) {
384
442
  parsed.system.splice(idx, 1);
385
443
  }
386
- // Always append a deterministic billing block at the end.
387
- // If the client sent one, we stripped its dynamic cch= and use
388
- // our stable version instead. If not, we add ours.
389
- parsed.system = [...parsed.system, billingBlock, agentBlock];
444
+ if (!isClaudeCodeClient && parsed.system.length > 0) {
445
+ // Non-CC client: relocate its system into the message stream so the
446
+ // subscription/OAuth path accepts the request, then send only the
447
+ // recognised billing + agent blocks as `system`.
448
+ relocateClientSystemIntoMessages(parsed, parsed.system);
449
+ parsed.system = [billingBlock, agentBlock];
450
+ }
451
+ else {
452
+ // Genuine Claude Code (or no extra blocks): keep the recognised system
453
+ // and append the deterministic billing + agent blocks at the end.
454
+ parsed.system = [...parsed.system, billingBlock, agentBlock];
455
+ }
390
456
  }
391
457
  }
392
458
  else {
@@ -680,6 +746,7 @@ async function handleClaudePassthroughStreamResponse(args) {
680
746
  cacheReadTokens: data.usage.cacheReadInputTokens,
681
747
  });
682
748
  capturedTracer.logStreamEvents(data.events);
749
+ capturedTracer.setResponseInfo(responseInfoFromStream(data));
683
750
  const rateLimit5h = parseFloat(capturedResponse.headers.get("anthropic-ratelimit-unified-5h-utilization") ?? "");
684
751
  const rateLimit7d = parseFloat(capturedResponse.headers.get("anthropic-ratelimit-unified-7d-utilization") ?? "");
685
752
  const usageUpdate = {
@@ -871,6 +938,7 @@ async function handleClaudePassthroughJsonResponse(args) {
871
938
  tracer.setUsage(usageWithRates);
872
939
  }
873
940
  }
941
+ tracer.setResponseInfo(extractResponseInfo(responseJson));
874
942
  tracer.recordMetrics();
875
943
  const responseJsonStr = JSON.stringify(responseJson);
876
944
  tracer.recordBodySizes(bodyStr.length, responseJsonStr.length);
@@ -1556,6 +1624,7 @@ function attachAnthropicSuccessStreamTelemetry(args) {
1556
1624
  cacheReadTokens: data.usage.cacheReadInputTokens,
1557
1625
  });
1558
1626
  capturedTracer.logStreamEvents(data.events);
1627
+ capturedTracer.setResponseInfo(responseInfoFromStream(data));
1559
1628
  const rateLimit5h = parseFloat(capturedResponse.headers.get("anthropic-ratelimit-unified-5h-utilization") ?? "");
1560
1629
  const rateLimit7d = parseFloat(capturedResponse.headers.get("anthropic-ratelimit-unified-7d-utilization") ?? "");
1561
1630
  const usageUpdate = {
@@ -1773,6 +1842,7 @@ async function handleAnthropicJsonSuccessResponse(args) {
1773
1842
  tracer.setUsage(usageWithRates);
1774
1843
  }
1775
1844
  }
1845
+ tracer.setResponseInfo(extractResponseInfo(responseJson));
1776
1846
  tracer.recordMetrics();
1777
1847
  const responseJsonStr = JSON.stringify(responseJson);
1778
1848
  tracer.recordBodySizes(finalBodyStr.length, responseJsonStr.length);
@@ -1875,6 +1945,7 @@ async function handleAnthropicSuccessfulRetryResponse(args) {
1875
1945
  cacheReadTokens: data.usage.cacheReadInputTokens,
1876
1946
  });
1877
1947
  capturedTracer.logStreamEvents(data.events);
1948
+ capturedTracer.setResponseInfo(responseInfoFromStream(data));
1878
1949
  capturedTracer.logUpstreamResponseHeaders(Object.fromEntries([...capturedRetryResp.headers.entries()]));
1879
1950
  capturedTracer.recordMetrics();
1880
1951
  capturedTracer.recordBodySizes(capturedRetryRequestBytes, data.totalBytesReceived);
@@ -1961,6 +2032,7 @@ async function handleAnthropicSuccessfulRetryResponse(args) {
1961
2032
  cacheReadTokens: retryUsage.cache_read_input_tokens ?? 0,
1962
2033
  });
1963
2034
  }
2035
+ tracer.setResponseInfo(extractResponseInfo(retryJson));
1964
2036
  tracer.recordMetrics();
1965
2037
  const retryJsonStr = JSON.stringify(retryJson);
1966
2038
  tracer.recordBodySizes(finalBodyStr.length, retryJsonStr.length);
@@ -2083,7 +2155,14 @@ async function handleAnthropicAuthRetry(args) {
2083
2155
  currentLastError = retryBody;
2084
2156
  logger.debug(`[proxy] retry ${authRetry + 1} failed: ${retryStatus} ${retryBody.substring(0, 120)}`);
2085
2157
  recordAttemptError(account.label, account.type, retryStatus);
2086
- if (retryStatus === 429) {
2158
+ // A real rate-limit 429 rotates accounts. But an anti-abuse / construction
2159
+ // 429 (no rate-limit headers, body "Error") is NOT a real rate limit:
2160
+ // advancing the primary or rotating cannot help and only burns quota. Skip
2161
+ // the rotate path for it so it falls through to the terminal error return
2162
+ // below, surfacing the truthful upstream error — mirroring the initial
2163
+ // fetch path's fast-fail (isAntiAbuseConstruction429).
2164
+ if (retryStatus === 429 &&
2165
+ !isAntiAbuseConstruction429(retryRespHeaders, retryBody)) {
2087
2166
  currentSawRateLimit = true;
2088
2167
  advancePrimaryIfCurrent(account.key, enabledAccounts.length, orderedAccounts[0]?.key);
2089
2168
  break;
@@ -2373,6 +2452,13 @@ function createClaudeRequestRuntimeContext(args) {
2373
2452
  model: body.model,
2374
2453
  stream: body.stream ?? false,
2375
2454
  toolCount: Array.isArray(body.tools) ? body.tools.length : 0,
2455
+ toolNames: Array.isArray(body.tools)
2456
+ ? body.tools
2457
+ .map((t) => t && typeof t === "object" && "name" in t
2458
+ ? String(t.name ?? "")
2459
+ : "")
2460
+ .filter((n) => n.length > 0)
2461
+ : undefined,
2376
2462
  sessionId: ctx.headers["x-neurolink-session-id"] ??
2377
2463
  ctx.headers["x-claude-code-session-id"] ??
2378
2464
  undefined,
@@ -2649,6 +2735,24 @@ async function prepareAnthropicAccountAttempt(args) {
2649
2735
  upstreamSpan,
2650
2736
  };
2651
2737
  }
2738
+ /**
2739
+ * Detect Anthropic's anti-abuse / request-construction 429.
2740
+ *
2741
+ * The subscription/OAuth path rejects requests it does not recognise as genuine
2742
+ * Claude Code traffic with a 429 `rate_limit_error` whose message is literally
2743
+ * "Error" and which carries NONE of the real rate-limit headers (no retry-after,
2744
+ * no anthropic-ratelimit-*). This is NOT a capacity limit — retrying or rotating
2745
+ * accounts cannot fix it and only burns quota, so the caller must fail fast and
2746
+ * surface the truthful upstream error instead of "all accounts rate-limited".
2747
+ */
2748
+ function isAntiAbuseConstruction429(headers, body) {
2749
+ const hasRetryAfter = !!headers["retry-after"];
2750
+ const hasRateLimitHeaders = Object.keys(headers).some((k) => k.toLowerCase().startsWith("anthropic-ratelimit-"));
2751
+ if (hasRetryAfter || hasRateLimitHeaders) {
2752
+ return false;
2753
+ }
2754
+ return (body.includes("rate_limit_error") && /"message"\s*:\s*"Error"/.test(body));
2755
+ }
2652
2756
  async function fetchAnthropicAccountResponse(args) {
2653
2757
  const { url, headers, finalBodyStr, account, accountState: _accountState2, enabledAccounts: _enabledAccounts, orderedAccounts: _orderedAccounts, tracer, logAttempt, logProxyBody, fetchStartMs, attemptNumber, currentLastError, currentSawRateLimit, currentSawNetworkError, upstreamSpan, } = args;
2654
2758
  let lastError = currentLastError;
@@ -2719,6 +2823,30 @@ async function fetchAnthropicAccountResponse(args) {
2719
2823
  responseStatus: 429,
2720
2824
  durationMs: Date.now() - fetchStartMs,
2721
2825
  });
2826
+ // Anti-abuse / request-construction 429 (no rate-limit headers, body
2827
+ // "Error"): rotating accounts cannot help and only burns quota. Fail fast
2828
+ // and surface the truthful upstream error instead of the misleading
2829
+ // "all accounts rate-limited" after 44 wasted attempts.
2830
+ if (isAntiAbuseConstruction429(errRespHeaders, String(lastError))) {
2831
+ logger.always(`[proxy] ← 429 account=${account.label} anti-abuse/construction rejection (no ratelimit headers, body="Error") — NOT a real rate limit; returning upstream error without rotating`);
2832
+ logAttempt(429, "construction_rejection", String(lastError));
2833
+ tracer?.setError("construction_rejection", String(lastError).slice(0, 500));
2834
+ currentUpstreamSpan?.end();
2835
+ const passthrough = new Response(String(lastError), {
2836
+ status: 429,
2837
+ headers: {
2838
+ "content-type": errRespHeaders["content-type"] ?? "application/json",
2839
+ },
2840
+ });
2841
+ return {
2842
+ continueLoop: false,
2843
+ response: passthrough,
2844
+ lastError,
2845
+ sawRateLimit,
2846
+ sawNetworkError,
2847
+ upstreamSpan: undefined,
2848
+ };
2849
+ }
2722
2850
  logger.always(`[proxy] ← 429 account=${account.label} retry-after=${retryAfterMs}ms (upstream) ratelimit-status=${errRespHeaders["anthropic-ratelimit-unified-status"] ?? "unknown"}`);
2723
2851
  logAttempt(429, "rate_limit_error", String(lastError));
2724
2852
  tracer?.setError("rate_limit_error", String(lastError).slice(0, 500));
@@ -771,10 +771,20 @@ export type ProxyRequestContext = {
771
771
  model: string;
772
772
  stream: boolean;
773
773
  toolCount: number;
774
+ /** Names of the tools advertised in the request (what the caller exposed). */
775
+ toolNames?: string[];
774
776
  sessionId?: string;
775
777
  userAgent?: string;
776
778
  clientApp?: string;
777
779
  };
780
+ /** Response-side details parsed from the upstream reply (model, finish, tools). */
781
+ export type ResponseInfoContext = {
782
+ responseModel?: string;
783
+ finishReason?: string;
784
+ stopSequence?: string;
785
+ /** Names of the tools the model actually invoked (tool_use blocks). */
786
+ toolCalls?: string[];
787
+ };
778
788
  /** Context recorded when an account is selected for a proxy request. */
779
789
  export type AccountSelectionContext = {
780
790
  strategy: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "9.79.1",
3
+ "version": "9.79.3",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applications with 21+ providers: OpenAI, Anthropic, Google AI Studio, Google Vertex, AWS Bedrock, Azure OpenAI, Mistral, LiteLLM, SageMaker, Hugging Face, Ollama, OpenAI-compatible, OpenRouter, DeepSeek, NVIDIA NIM, LM Studio, llama.cpp, plus voice (OpenAI TTS, ElevenLabs, Deepgram, Azure Speech).",
6
6
  "author": {
@@ -118,6 +118,9 @@
118
118
  "test:auth": "npx tsx test/continuous-test-suite-auth.ts",
119
119
  "test:autoresearch": "npx tsx test/continuous-test-suite-autoresearch.ts",
120
120
  "test:autoresearch:redis": "npx tsx test/continuous-test-suite-autoresearch-redis.ts",
121
+ "test:anthropic-tools-policy": "npx tsx test/continuous-test-suite-anthropic-tools-policy.ts",
122
+ "test:anthropic-multimodal": "npx tsx test/continuous-test-suite-anthropic-multimodal.ts",
123
+ "test:excel-interop": "npx tsx test/continuous-test-suite-excel-interop.ts",
121
124
  "test:envguard": "npx tsx test/helpers/envGuard.test.ts",
122
125
  "test:tool-routing-cli:vitest": "pnpm exec vitest run test/toolRoutingCli.test.ts",
123
126
  "test:tool-routing-cli": "pnpm run test:tool-routing-cli:vitest && npx tsx test/continuous-test-suite-tool-routing-cli.ts",
@@ -131,7 +134,7 @@
131
134
  "test:tool-routing": "pnpm run test:unit:vitest && npx tsx test/continuous-test-suite-tool-routing.ts",
132
135
  "test:tool-routing-semantic:vitest": "pnpm exec vitest run test/toolRoutingSemantic.test.ts",
133
136
  "test:tool-routing-semantic": "pnpm run test:tool-routing-semantic:vitest && npx tsx test/continuous-test-suite-tool-routing-semantic.ts",
134
- "test:unit": "pnpm run test:envguard && pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:bash && pnpm run test:mcp:limits && pnpm run test:mcp:spans && pnpm run test:autoresearch:redis && pnpm run test:unit:vitest && pnpm run test:tool-routing-cli:vitest && pnpm run test:tool-dedup:vitest && pnpm run test:model-pool:vitest && pnpm run test:tool-routing-semantic:vitest",
137
+ "test:unit": "pnpm run test:envguard && pnpm run test:bugfixes && pnpm run test:mcp:infra && pnpm run test:mcp:bash && pnpm run test:mcp:limits && pnpm run test:mcp:spans && pnpm run test:autoresearch:redis && pnpm run test:unit:vitest && pnpm run test:tool-routing-cli:vitest && pnpm run test:tool-dedup:vitest && pnpm run test:model-pool:vitest && pnpm run test:tool-routing-semantic:vitest && pnpm run test:anthropic-tools-policy && pnpm run test:anthropic-multimodal && pnpm run test:excel-interop",
135
138
  "// CI tier — live providers, runs only when API keys are present (test:credentials and test:dynamic make real provider calls when keys are set, so they live here, not in test:unit)": "",
136
139
  "test:live": "pnpm run test:providers && pnpm run test:mcp:http && pnpm run test:mcp:sdk && pnpm run test:mcp:cli && pnpm run test:observability && pnpm run test:context && pnpm run test:memory && pnpm run test:tool-reliability && pnpm run test:evaluation && pnpm run test:autoresearch && pnpm run test:credentials && pnpm run test:dynamic",
137
140
  "// CI tier — product output (image/video/TTS/PPT) — costs $$ per run": "",
@@ -598,6 +601,9 @@
598
601
  "rollup@>=4.0.0 <4.59.0": ">=4.59.0",
599
602
  "shell-quote@<1.8.4": ">=1.8.4",
600
603
  "undici@>=8.0.0": ">=7.24.0 <8.0.0"
604
+ },
605
+ "patchedDependencies": {
606
+ "mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
601
607
  }
602
608
  },
603
609
  "os": [