@listen-fire/anthropic 0.12.0-rc.7 → 0.12.0

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/dist/chat.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import type Anthropic from '@anthropic-ai/sdk';
2
2
  import type { ChatModelName, Resolved } from '@listen-fire/engine/public/models';
3
3
  export type ChatProvider = {
4
+ /** A stable, non-reversible id of the API key this provider calls on, when
5
+ * it has one: Anthropic's limits are per key, so the limiter counts and
6
+ * throttles each key apart. A provider with none shares one module-wide. */
7
+ readonly credential?: string;
4
8
  messages: {
5
9
  create(params: Anthropic.MessageCreateParamsNonStreaming, options?: Anthropic.RequestOptions): Promise<Anthropic.Message>;
6
10
  stream(params: Anthropic.MessageCreateParamsNonStreaming, options?: Anthropic.RequestOptions): {
package/dist/client.js CHANGED
@@ -10,55 +10,73 @@ const node_async_hooks_1 = require("node:async_hooks");
10
10
  const node_crypto_1 = require("node:crypto");
11
11
  const exponential_backoff_1 = require("exponential-backoff");
12
12
  const behaviour_json_1 = __importDefault(require("./behaviour.json"));
13
- const retry_after_1 = require("./retry_after");
14
13
  const structured_1 = require("./structured");
15
14
  const web_tools_1 = require("./web_tools");
15
+ const keyed_provider_1 = require("./keyed_provider");
16
16
  const limiter_1 = require("@listen-fire/engine/public/limiter");
17
17
  const log_1 = require("@listen-fire/engine/public/log");
18
18
  const models_1 = require("@listen-fire/engine/public/models");
19
19
  const types_1 = require("@listen-fire/engine/public/types");
20
20
  const SECOND = 1000;
21
- /** Requests in flight at once: `@lf/anthropic`'s own declared limit, kept by
22
- * the limiter slot (services/limiter), as lf's Rust client keeps it. A
23
- * request holds its place through its retries. */
24
- const MODELS_REQUEST = (0, limiter_1.limitRequest)({ module: '@lf/anthropic', team: '' }, [
25
- { scope: 'module', concurrent: behaviour_json_1.default.concurrentRequests },
26
- ]);
21
+ function modelsCaller(provider) {
22
+ return (0, keyed_provider_1.modelsCallerFor)(provider.credential);
23
+ }
24
+ /** Requests in flight at once, per key: `@lf/anthropic`'s own declared limit,
25
+ * kept by the limiter slot (services/limiter), as lf's Rust client keeps it.
26
+ * A request holds its place through its retries, but not through a vendor's
27
+ * rate limit: that is a throttle on the caller, waited out in the queue. */
28
+ function modelsRequest(caller) {
29
+ return (0, limiter_1.limitRequest)(caller, [{ scope: 'credential', concurrent: behaviour_json_1.default.concurrentRequests }]);
30
+ }
27
31
  // behaviour.json, which the Rust lf's client is held to as well.
28
32
  const RETRY_LIMIT = behaviour_json_1.default.retry.attempts;
29
33
  const INITIAL_DELAY = behaviour_json_1.default.retry.initialDelayMs;
30
34
  const TIME_MULTIPLE = behaviour_json_1.default.retry.multiple;
31
- const RATE_LIMIT_DELAY = behaviour_json_1.default.retry.rateLimitWaitMs;
32
- async function enqueueQuery(fn, signal) {
35
+ const RATE_LIMIT_WAIT = behaviour_json_1.default.retry.rateLimitWaitMs;
36
+ function rateLimited(e) {
37
+ return e instanceof Error && e.message.includes('429');
38
+ }
39
+ async function enqueueQuery(provider, fn, signal) {
40
+ const caller = modelsCaller(provider);
41
+ const request = modelsRequest(caller);
33
42
  // Every request — each turn of a tool loop, each continuation — first asks
34
43
  // whether its run has spent its cost cap. Here, in the caller's own async
35
44
  // context: the queued job may be started from another job's.
36
45
  (0, models_1.assertRunBudget)();
37
- return (0, limiter_1.limited)((0, limiter_1.limiterSlot)(), MODELS_REQUEST, async () => {
38
- return (0, exponential_backoff_1.backOff)(fn, {
39
- jitter: 'none',
40
- numOfAttempts: RETRY_LIMIT,
41
- startingDelay: INITIAL_DELAY,
42
- timeMultiple: TIME_MULTIPLE,
43
- retry: async (e, attempt) => {
44
- if (signal?.aborted)
45
- return false;
46
- if (e instanceof Error && e.message.includes('429')) {
47
- const asked = (0, retry_after_1.rateLimitDelayMs)(e);
48
- const wait = asked ?? RATE_LIMIT_DELAY;
49
- log_1.logger.info(`Hit model vendor rate limit, waiting ${wait / SECOND}s (${asked === undefined ? 'default' : 'from retry-after'})`);
50
- await new Promise((resolve) => setTimeout(resolve, wait));
51
- }
52
- else if (e instanceof Error && e.message.includes('401')) {
53
- return false;
54
- }
55
- if (attempt < RETRY_LIMIT) {
56
- log_1.logger.info(`Retrying Anthropic query in ${(INITIAL_DELAY / SECOND) * TIME_MULTIPLE ** (attempt - 1)}s`, { error: e });
57
- }
58
- return true;
59
- },
60
- });
61
- });
46
+ for (let rateLimits = 1;; rateLimits++) {
47
+ try {
48
+ return await (0, limiter_1.limited)((0, limiter_1.limiterSlot)(), request, async () => {
49
+ return (0, exponential_backoff_1.backOff)(fn, {
50
+ jitter: 'none',
51
+ numOfAttempts: RETRY_LIMIT,
52
+ startingDelay: INITIAL_DELAY,
53
+ timeMultiple: TIME_MULTIPLE,
54
+ retry: async (e, attempt) => {
55
+ if (signal?.aborted)
56
+ return false;
57
+ // Out of the permit: the throttle below holds every request
58
+ // made as this caller, not just this one.
59
+ if (rateLimited(e))
60
+ return false;
61
+ if (e instanceof Error && e.message.includes('401'))
62
+ return false;
63
+ if (attempt < RETRY_LIMIT) {
64
+ log_1.logger.info(`Retrying Anthropic query in ${(INITIAL_DELAY / SECOND) * TIME_MULTIPLE ** (attempt - 1)}s`, { error: e });
65
+ }
66
+ return true;
67
+ },
68
+ });
69
+ });
70
+ }
71
+ catch (e) {
72
+ if (!rateLimited(e) || signal?.aborted || rateLimits >= RETRY_LIMIT)
73
+ throw e;
74
+ const asked = (0, limiter_1.vendorWaitMs)(e);
75
+ const wait = asked ?? RATE_LIMIT_WAIT;
76
+ log_1.logger.info(`Hit model vendor rate limit, throttling model requests for ${wait / SECOND}s (${asked === undefined ? 'default' : 'from retry-after'})`);
77
+ await (0, limiter_1.throttleAfter)((0, limiter_1.limiterSlot)(), caller, wait);
78
+ }
79
+ }
62
80
  }
63
81
  function emptyTally() {
64
82
  return {
@@ -241,7 +259,7 @@ async function anthropicToolLoop(access, params, toolImpls = {}) {
241
259
  throw new Error(`Anthropic tool loop exhausted its turn budget (maxTurns=${maxTurns})${label ? ` in ${label}` : ''} — the agent never finished.`);
242
260
  }
243
261
  const llmStart = Date.now();
244
- const response = await enqueueQuery(async () => {
262
+ const response = await enqueueQuery(client, async () => {
245
263
  return client.messages.create({
246
264
  model: wireModel,
247
265
  max_tokens: max_output_tokens,
@@ -563,7 +581,7 @@ async function anthropicChatDetailed(access, options) {
563
581
  // Streamed rather than awaited whole: above ~16K `max_tokens` a
564
582
  // non-streaming request outlives the SDK's HTTP timeout. `finalMessage()`
565
583
  // reassembles the same `Message`, so nothing downstream changes.
566
- response = await enqueueQuery(async () => {
584
+ response = await enqueueQuery(client, async () => {
567
585
  const stream = client.messages.stream({
568
586
  model: wireModel,
569
587
  max_tokens: maxTokens,
@@ -704,7 +722,7 @@ async function anthropicChatStructured(access, options) {
704
722
  const { client, wireModel, resolved } = access.chatCallFor(model);
705
723
  const tool = (0, structured_1.buildStructuredTool)({ name: toolName, description: toolDescription, schema });
706
724
  const startMs = Date.now();
707
- const response = await enqueueQuery(async () => client.messages.create({
725
+ const response = await enqueueQuery(client, async () => client.messages.create({
708
726
  model: wireModel,
709
727
  max_tokens: maxTokens,
710
728
  system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }],
@@ -932,7 +950,7 @@ async function anthropicWebChat(access, options) {
932
950
  const settleWatch = watchSlowCall({ ...callFields, turn });
933
951
  let response;
934
952
  try {
935
- response = await enqueueQuery(async () => {
953
+ response = await enqueueQuery(client, async () => {
936
954
  const stream = client.messages.stream({
937
955
  model: wireModel,
938
956
  max_tokens: maxTokens,
@@ -1,7 +1,15 @@
1
+ import { type Caller } from '@listen-fire/engine/public/limiter';
1
2
  import type { ChatProvider } from './chat';
2
3
  export interface AnthropicKey {
3
4
  apiKey: string;
4
5
  /** Another endpoint speaking the same Messages API (a proxy, a test fake). */
5
6
  baseUrl?: string;
6
7
  }
8
+ /** What the limiter knows a key by: a hash, so neither the key nor any prefix
9
+ * of it reaches a throttle key or a shared limiter's store. */
10
+ export declare function credentialId(apiKey: string): string;
11
+ /** Who a model request is made as: the API key it calls on (Anthropic's
12
+ * limits are per key), so a 429 on one key leaves the others open. A provider
13
+ * with no key of its own is one caller, module-wide. */
14
+ export declare function modelsCallerFor(credential: string | undefined): Caller;
7
15
  export declare function anthropicKeyedChatProvider({ apiKey, baseUrl }: AnthropicKey): ChatProvider;
@@ -5,16 +5,48 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  return (mod && mod.__esModule) ? mod : { "default": mod };
6
6
  };
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.credentialId = credentialId;
9
+ exports.modelsCallerFor = modelsCallerFor;
8
10
  exports.anthropicKeyedChatProvider = anthropicKeyedChatProvider;
11
+ const node_crypto_1 = require("node:crypto");
9
12
  const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
13
+ const limiter_1 = require("@listen-fire/engine/public/limiter");
10
14
  // One per key rather than one per process: a unit that pays with its own key
11
15
  // (knowledge's `KNOWLEDGE_LLM_API_KEY`) must not share the platform's client.
12
16
  const keyedClients = new Map();
17
+ /** What the limiter knows a key by: a hash, so neither the key nor any prefix
18
+ * of it reaches a throttle key or a shared limiter's store. */
19
+ function credentialId(apiKey) {
20
+ return `sha256:${(0, node_crypto_1.createHash)('sha256').update(apiKey).digest('hex').slice(0, 16)}`;
21
+ }
22
+ /** Who a model request is made as: the API key it calls on (Anthropic's
23
+ * limits are per key), so a 429 on one key leaves the others open. A provider
24
+ * with no key of its own is one caller, module-wide. */
25
+ function modelsCallerFor(credential) {
26
+ return { module: '@lf/anthropic', team: '', ...(credential !== undefined ? { credential } : {}) };
27
+ }
28
+ /** Every response the key's client receives reports the key's remaining
29
+ * budget; a spent one closes the key before the 429 would. A failed
30
+ * response is the handler's (it reads the 429's own Retry-After). */
31
+ function fetchReportingBudget(credential) {
32
+ return async (input, init) => {
33
+ const response = await fetch(input, init);
34
+ if (response.ok)
35
+ await (0, limiter_1.budgetAfter)((0, limiter_1.limiterSlot)(), modelsCallerFor(credential), (0, limiter_1.vendorBudget)(response.headers));
36
+ return response;
37
+ };
38
+ }
13
39
  function anthropicKeyedChatProvider({ apiKey, baseUrl }) {
14
40
  const cacheKey = `${baseUrl ?? ''}\n${apiKey}`;
15
41
  let keyed = keyedClients.get(cacheKey);
16
42
  if (!keyed) {
17
- keyed = new sdk_1.default({ apiKey, ...(baseUrl !== undefined ? { baseURL: baseUrl } : {}) });
43
+ // No SDK retries: a 429 must reach the module's handler at once, which
44
+ // releases the permit and throttles the key for everyone sharing the
45
+ // limiter. The SDK would sleep on it (twice) inside the held permit.
46
+ // The client's own backoff retries the other transient failures.
47
+ const credential = credentialId(apiKey);
48
+ const sdk = new sdk_1.default({ apiKey, maxRetries: 0, fetch: fetchReportingBudget(credential), ...(baseUrl !== undefined ? { baseURL: baseUrl } : {}) });
49
+ keyed = { credential, messages: sdk.messages };
18
50
  keyedClients.set(cacheKey, keyed);
19
51
  }
20
52
  return keyed;
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@listen-fire/anthropic",
3
3
  "license": "Apache-2.0",
4
- "version": "0.12.0-rc.7",
4
+ "version": "0.12.0",
5
5
  "description": "The models slot's module for Claude (`@lf/anthropic`): the client every model call goes through (chat, structured output, the tool loop, web chat with Anthropic's hosted search and fetch), the chat seam a provider stands behind, Anthropic's API on a key the caller holds, the price table every call is billed from (prices.json, which the Rust lf embeds too), and the extraction and `AI()` clients the engine's models slot is filled with.",
6
6
  "engines": {
7
- "node": "22.20.0"
7
+ "node": ">=22.23.4 <23"
8
8
  },
9
9
  "main": "./dist/index.js",
10
10
  "types": "./dist/index.d.ts",
@@ -14,7 +14,7 @@
14
14
  "exponential-backoff": "3.1.1",
15
15
  "jsonrepair": "3.8.0",
16
16
  "zod": "4.1.12",
17
- "@listen-fire/engine": "^0.12.0-rc.7"
17
+ "@listen-fire/engine": "^0.12.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/jest": "29.5.14",
@@ -30,8 +30,7 @@
30
30
  "dist"
31
31
  ],
32
32
  "publishConfig": {
33
- "access": "public",
34
- "tag": "next"
33
+ "access": "public"
35
34
  },
36
35
  "scripts": {
37
36
  "code:type-check": "tsc --pretty --noEmit && node ../../scripts/check-module-boundary.cjs",
@@ -1,6 +0,0 @@
1
- /**
2
- * How long the vendor asked a rate-limited caller to wait, or undefined when
3
- * it did not say. Clamped to [1s, 120s] so a hostile or absurd value can
4
- * neither spin nor stall a queue slot.
5
- */
6
- export declare function rateLimitDelayMs(e: unknown, now?: number): number | undefined;
@@ -1,57 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.rateLimitDelayMs = rateLimitDelayMs;
4
- const SECOND = 1000;
5
- const MIN_DELAY = 1 * SECOND;
6
- const MAX_DELAY = 120 * SECOND;
7
- function clamp(ms) {
8
- return Math.min(MAX_DELAY, Math.max(MIN_DELAY, Math.round(ms)));
9
- }
10
- /** `Retry-After` is either whole seconds or an HTTP date. */
11
- function fromHeaderValue(value, now) {
12
- const trimmed = value.trim();
13
- if (/^\d+(\.\d+)?$/.test(trimmed))
14
- return Number(trimmed) * SECOND;
15
- const date = Date.parse(trimmed);
16
- return Number.isNaN(date) ? undefined : date - now;
17
- }
18
- function readHeader(headers, name) {
19
- if (!headers || typeof headers !== 'object')
20
- return undefined;
21
- if ('get' in headers && typeof headers.get === 'function') {
22
- const value = headers.get(name);
23
- return typeof value === 'string' ? value : undefined;
24
- }
25
- for (const [key, value] of Object.entries(headers)) {
26
- if (key.toLowerCase() === name && typeof value === 'string')
27
- return value;
28
- }
29
- return undefined;
30
- }
31
- /**
32
- * Google's `google.rpc.RetryInfo` detail: `"retryDelay": "17s"` (or
33
- * `"17.5s"`) on the wire, `{ seconds: 17 }` in a decoded form. The genai SDK's
34
- * `ApiError` carries the JSON body only as text in its message.
35
- */
36
- function fromRetryInfo(text) {
37
- const quoted = /"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"/.exec(text);
38
- if (quoted)
39
- return Number(quoted[1]) * SECOND;
40
- const object = /"retryDelay"\s*:\s*\{\s*"seconds"\s*:\s*"?(\d+)"?/.exec(text);
41
- return object ? Number(object[1]) * SECOND : undefined;
42
- }
43
- /**
44
- * How long the vendor asked a rate-limited caller to wait, or undefined when
45
- * it did not say. Clamped to [1s, 120s] so a hostile or absurd value can
46
- * neither spin nor stall a queue slot.
47
- */
48
- function rateLimitDelayMs(e, now = Date.now()) {
49
- const headers = typeof e === 'object' && e !== null && 'headers' in e ? e.headers : undefined;
50
- const header = readHeader(headers, 'retry-after');
51
- const fromHeader = header === undefined ? undefined : fromHeaderValue(header, now);
52
- if (fromHeader !== undefined)
53
- return clamp(fromHeader);
54
- const text = e instanceof Error ? e.message : undefined;
55
- const fromBody = text === undefined ? undefined : fromRetryInfo(text);
56
- return fromBody === undefined ? undefined : clamp(fromBody);
57
- }