@listen-fire/anthropic 0.12.0-rc.8 → 0.12.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/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
@@ -12,21 +12,22 @@ const exponential_backoff_1 = require("exponential-backoff");
12
12
  const behaviour_json_1 = __importDefault(require("./behaviour.json"));
13
13
  const structured_1 = require("./structured");
14
14
  const web_tools_1 = require("./web_tools");
15
+ const keyed_provider_1 = require("./keyed_provider");
15
16
  const limiter_1 = require("@listen-fire/engine/public/limiter");
16
17
  const log_1 = require("@listen-fire/engine/public/log");
17
18
  const models_1 = require("@listen-fire/engine/public/models");
18
19
  const types_1 = require("@listen-fire/engine/public/types");
19
20
  const SECOND = 1000;
20
- /** Who every model request is made as: where the vendor's rate limit lands
21
- * (its throttle key) and what `@lf/anthropic`'s declared limit counts. */
22
- const MODELS_CALLER = { module: '@lf/anthropic', team: '' };
23
- /** Requests in flight at once: `@lf/anthropic`'s own declared limit, kept by
24
- * the limiter slot (services/limiter), as lf's Rust client keeps it. A
25
- * request holds its place through its retries, but not through a vendor's
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
26
27
  * rate limit: that is a throttle on the caller, waited out in the queue. */
27
- const MODELS_REQUEST = (0, limiter_1.limitRequest)(MODELS_CALLER, [
28
- { scope: 'module', concurrent: behaviour_json_1.default.concurrentRequests },
29
- ]);
28
+ function modelsRequest(caller) {
29
+ return (0, limiter_1.limitRequest)(caller, [{ scope: 'credential', concurrent: behaviour_json_1.default.concurrentRequests }]);
30
+ }
30
31
  // behaviour.json, which the Rust lf's client is held to as well.
31
32
  const RETRY_LIMIT = behaviour_json_1.default.retry.attempts;
32
33
  const INITIAL_DELAY = behaviour_json_1.default.retry.initialDelayMs;
@@ -35,14 +36,16 @@ const RATE_LIMIT_WAIT = behaviour_json_1.default.retry.rateLimitWaitMs;
35
36
  function rateLimited(e) {
36
37
  return e instanceof Error && e.message.includes('429');
37
38
  }
38
- async function enqueueQuery(fn, signal) {
39
+ async function enqueueQuery(provider, fn, signal) {
40
+ const caller = modelsCaller(provider);
41
+ const request = modelsRequest(caller);
39
42
  // Every request — each turn of a tool loop, each continuation — first asks
40
43
  // whether its run has spent its cost cap. Here, in the caller's own async
41
44
  // context: the queued job may be started from another job's.
42
45
  (0, models_1.assertRunBudget)();
43
46
  for (let rateLimits = 1;; rateLimits++) {
44
47
  try {
45
- return await (0, limiter_1.limited)((0, limiter_1.limiterSlot)(), MODELS_REQUEST, async () => {
48
+ return await (0, limiter_1.limited)((0, limiter_1.limiterSlot)(), request, async () => {
46
49
  return (0, exponential_backoff_1.backOff)(fn, {
47
50
  jitter: 'none',
48
51
  numOfAttempts: RETRY_LIMIT,
@@ -71,7 +74,7 @@ async function enqueueQuery(fn, signal) {
71
74
  const asked = (0, limiter_1.vendorWaitMs)(e);
72
75
  const wait = asked ?? RATE_LIMIT_WAIT;
73
76
  log_1.logger.info(`Hit model vendor rate limit, throttling model requests for ${wait / SECOND}s (${asked === undefined ? 'default' : 'from retry-after'})`);
74
- await (0, limiter_1.throttleAfter)((0, limiter_1.limiterSlot)(), MODELS_CALLER, wait);
77
+ await (0, limiter_1.throttleAfter)((0, limiter_1.limiterSlot)(), caller, wait);
75
78
  }
76
79
  }
77
80
  }
@@ -256,7 +259,7 @@ async function anthropicToolLoop(access, params, toolImpls = {}) {
256
259
  throw new Error(`Anthropic tool loop exhausted its turn budget (maxTurns=${maxTurns})${label ? ` in ${label}` : ''} — the agent never finished.`);
257
260
  }
258
261
  const llmStart = Date.now();
259
- const response = await enqueueQuery(async () => {
262
+ const response = await enqueueQuery(client, async () => {
260
263
  return client.messages.create({
261
264
  model: wireModel,
262
265
  max_tokens: max_output_tokens,
@@ -578,7 +581,7 @@ async function anthropicChatDetailed(access, options) {
578
581
  // Streamed rather than awaited whole: above ~16K `max_tokens` a
579
582
  // non-streaming request outlives the SDK's HTTP timeout. `finalMessage()`
580
583
  // reassembles the same `Message`, so nothing downstream changes.
581
- response = await enqueueQuery(async () => {
584
+ response = await enqueueQuery(client, async () => {
582
585
  const stream = client.messages.stream({
583
586
  model: wireModel,
584
587
  max_tokens: maxTokens,
@@ -719,7 +722,7 @@ async function anthropicChatStructured(access, options) {
719
722
  const { client, wireModel, resolved } = access.chatCallFor(model);
720
723
  const tool = (0, structured_1.buildStructuredTool)({ name: toolName, description: toolDescription, schema });
721
724
  const startMs = Date.now();
722
- const response = await enqueueQuery(async () => client.messages.create({
725
+ const response = await enqueueQuery(client, async () => client.messages.create({
723
726
  model: wireModel,
724
727
  max_tokens: maxTokens,
725
728
  system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }],
@@ -947,7 +950,7 @@ async function anthropicWebChat(access, options) {
947
950
  const settleWatch = watchSlowCall({ ...callFields, turn });
948
951
  let response;
949
952
  try {
950
- response = await enqueueQuery(async () => {
953
+ response = await enqueueQuery(client, async () => {
951
954
  const stream = client.messages.stream({
952
955
  model: wireModel,
953
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.8",
4
+ "version": "0.12.1",
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.8"
17
+ "@listen-fire/engine": "^0.12.1"
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",