@stratchai/llm 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 stratchai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @stratchai/llm
2
+
3
+ Provider-agnostic **forced-JSON** LLM adapter for the stratchai trading agents. One interface,
4
+ two providers — [Anthropic](https://docs.anthropic.com) (forced tool-use + prompt caching) and
5
+ [OpenRouter](https://openrouter.ai/docs) (OpenAI-style function calling) — with **cache-aware
6
+ per-call cost accounting** so budget meters stay honest across vendors.
7
+
8
+ Built for decision gates that must never act on malformed output: the only operation offered is
9
+ *"produce an object matching this schema, or throw."* Free-text JSON is deliberately not exposed —
10
+ it empties/mis-formats in bursts on some models (~34% of live calls observed on our crypto gate),
11
+ and a silently-defaulted decision is worse than a loud error.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @stratchai/llm
17
+ ```
18
+
19
+ Zero runtime dependencies (built on `fetch`, Node ≥ 18).
20
+
21
+ ## Use
22
+
23
+ ```ts
24
+ import { createProvider } from '@stratchai/llm'
25
+
26
+ const llm = createProvider({ model: process.env.GATE_MODEL }) // provider from LLM_PROVIDER (default anthropic)
27
+
28
+ const res = await llm.jsonCall({
29
+ system: STATIC_RULEBOOK, // byte-identical across calls → provider prompt-caching engages
30
+ user: perCandidatePrompt,
31
+ tool: {
32
+ name: 'record_verdict',
33
+ description: 'Record the entry-gate verdict.',
34
+ schema: {
35
+ type: 'object',
36
+ properties: {
37
+ take: { type: 'boolean' },
38
+ reason: { type: 'string' },
39
+ confidence: { type: 'number' },
40
+ },
41
+ required: ['take', 'reason', 'confidence'],
42
+ },
43
+ },
44
+ })
45
+ res.object // schema-shaped verdict (never null — failure throws)
46
+ res.usage.costUsd // cache-aware $ for this call
47
+ ```
48
+
49
+ ### Error contract (load-bearing)
50
+
51
+ - Transport/HTTP failures **throw with `.status`** set to the HTTP code.
52
+ - Parse/shape failures (missing tool call, bad JSON) **throw with `.status` undefined**.
53
+
54
+ Callers that retry only on `e.status == null` — the parse-only retry pattern our agents use, since
55
+ HTTP-level retries belong to backoff logic — work unchanged across both providers. The recommended
56
+ gate pattern is **fail-closed**: catch, log, and return a conservative skip.
57
+
58
+ ## Configuration
59
+
60
+ | env | meaning | default |
61
+ |---|---|---|
62
+ | `LLM_PROVIDER` | `anthropic` \| `openrouter` | `anthropic` |
63
+ | `LLM_MODEL` | model id in the provider's namespace | — (callers usually pass `model`) |
64
+ | `ANTHROPIC_API_KEY` / `OPENROUTER_API_KEY` | credentials | — |
65
+ | `LLM_PRICE_IN_PER_MTOK` / `LLM_PRICE_OUT_PER_MTOK` | $/1M-token overrides for cost metering | built-in table |
66
+
67
+ Built-in prices (USD per 1M tokens): Anthropic haiku 1/5, sonnet 3/15, opus 15/75 — cache reads
68
+ at 0.1×, cache writes at 1.25×. OpenRouter: deepseek 0.3/1.2, llama-3.3-70b 0.2/0.6, qwen 0.3/1.2;
69
+ **unknown models fall back conservative (1/2)** so a budget ceiling errs on the safe side —
70
+ override the price envs when running anything else.
71
+
72
+ ### Provider notes
73
+
74
+ - **Anthropic**: `tool_choice` forces the verdict tool; the system block carries
75
+ `cache_control: ephemeral` (disable per-call with `cacheSystem: false`).
76
+ - **OpenRouter**: function calling is the primary path. If the chosen model rejects tools
77
+ (4xx mentioning tools), one fallback attempt uses `response_format: json_object` plus a
78
+ schema-in-prompt instruction with first-`{…}` extraction. Prompt caching is automatic on
79
+ supporting models (no marker to send); reported cached tokens are billed at the read discount.
80
+
81
+ ## Why this exists
82
+
83
+ Our agents are published to npm and must not be vendor-locked, but their decision gates carry a
84
+ forward evidence record earned on a specific model. This package makes the provider a config
85
+ choice while changing nothing about behavior on the default path — swap-safety is the point:
86
+ promote a cheaper provider only after it earns the switch in a shadow A/B, not because the
87
+ adapter made it easy.
88
+
89
+ ## Development
90
+
91
+ ```bash
92
+ npm test # tsc build + node:test (mocked fetch — no network, no keys)
93
+ ```
94
+
95
+ Issues: https://github.com/stratchai/llm/issues
@@ -0,0 +1,20 @@
1
+ /**
2
+ * AnthropicProvider — forced tool-use against the Messages API (raw fetch, zero deps).
3
+ * Preserves the crypto-gate's proven mechanics exactly:
4
+ * • tool_choice forces a schema-valid verdict (free-text JSON empties in bursts; some
5
+ * models reject assistant prefill — the forced tool call is the reliable path).
6
+ * • the system block carries cache_control: ephemeral (byte-identical prefix across
7
+ * calls → prompt-cache reads at 0.1× price).
8
+ * • usage is reported cache-aware and costed with the same math as gate-cost.ts.
9
+ */
10
+ import { JsonCallRequest, JsonCallResult, LlmProvider } from './types.js';
11
+ import { Price } from './pricing.js';
12
+ export declare class AnthropicProvider implements LlmProvider {
13
+ private apiKey;
14
+ readonly model: string;
15
+ private priceOverride?;
16
+ private timeoutMs;
17
+ readonly id: "anthropic";
18
+ constructor(apiKey: string, model: string, priceOverride?: Partial<Price> | undefined, timeoutMs?: number);
19
+ jsonCall(req: JsonCallRequest): Promise<JsonCallResult>;
20
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * AnthropicProvider — forced tool-use against the Messages API (raw fetch, zero deps).
3
+ * Preserves the crypto-gate's proven mechanics exactly:
4
+ * • tool_choice forces a schema-valid verdict (free-text JSON empties in bursts; some
5
+ * models reject assistant prefill — the forced tool call is the reliable path).
6
+ * • the system block carries cache_control: ephemeral (byte-identical prefix across
7
+ * calls → prompt-cache reads at 0.1× price).
8
+ * • usage is reported cache-aware and costed with the same math as gate-cost.ts.
9
+ */
10
+ import { LlmError } from './types.js';
11
+ import { computeCost, priceFor } from './pricing.js';
12
+ const API = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com';
13
+ export class AnthropicProvider {
14
+ apiKey;
15
+ model;
16
+ priceOverride;
17
+ timeoutMs;
18
+ id = 'anthropic';
19
+ constructor(apiKey, model, priceOverride, timeoutMs = 60_000) {
20
+ this.apiKey = apiKey;
21
+ this.model = model;
22
+ this.priceOverride = priceOverride;
23
+ this.timeoutMs = timeoutMs;
24
+ if (!apiKey)
25
+ throw new LlmError('AnthropicProvider: missing API key');
26
+ if (!model)
27
+ throw new LlmError('AnthropicProvider: missing model');
28
+ }
29
+ async jsonCall(req) {
30
+ const cache = req.cacheSystem !== false;
31
+ const body = {
32
+ model: this.model,
33
+ max_tokens: req.maxTokens ?? 512,
34
+ system: cache
35
+ ? [{ type: 'text', text: req.system, cache_control: { type: 'ephemeral' } }]
36
+ : req.system,
37
+ tools: [{ name: req.tool.name, description: req.tool.description ?? '', input_schema: req.tool.schema }],
38
+ tool_choice: { type: 'tool', name: req.tool.name },
39
+ messages: [{ role: 'user', content: req.user }],
40
+ };
41
+ let res;
42
+ try {
43
+ res = await fetch(`${API}/v1/messages`, {
44
+ method: 'POST',
45
+ headers: {
46
+ 'content-type': 'application/json',
47
+ 'x-api-key': this.apiKey,
48
+ 'anthropic-version': '2023-06-01',
49
+ },
50
+ body: JSON.stringify(body),
51
+ signal: AbortSignal.timeout(this.timeoutMs),
52
+ });
53
+ }
54
+ catch (e) {
55
+ throw new LlmError(`anthropic transport: ${e?.message || e}`); // no status → caller may retry
56
+ }
57
+ if (!res.ok) {
58
+ const detail = await res.text().catch(() => '');
59
+ throw new LlmError(`anthropic ${res.status}: ${detail.slice(0, 300)}`, res.status);
60
+ }
61
+ const d = await res.json();
62
+ const tu = (d.content ?? []).find((b) => b.type === 'tool_use');
63
+ if (!tu?.input || typeof tu.input !== 'object')
64
+ throw new LlmError('no tool_use in response');
65
+ const u = d.usage ?? {};
66
+ const inTok = u.input_tokens ?? 0;
67
+ const cacheRead = u.cache_read_input_tokens ?? 0;
68
+ const cacheWrite = u.cache_creation_input_tokens ?? 0;
69
+ const outTok = u.output_tokens ?? 0;
70
+ const price = priceFor('anthropic', this.model, this.priceOverride);
71
+ return {
72
+ object: tu.input,
73
+ raw: JSON.stringify(tu.input),
74
+ usage: {
75
+ inputTokens: inTok + cacheRead + cacheWrite,
76
+ outputTokens: outTok,
77
+ cacheReadTokens: cacheRead,
78
+ cacheWriteTokens: cacheWrite,
79
+ costUsd: computeCost(price, inTok, outTok, cacheRead, cacheWrite),
80
+ },
81
+ provider: this.id,
82
+ model: this.model,
83
+ };
84
+ }
85
+ }
@@ -0,0 +1,15 @@
1
+ export * from './types.js';
2
+ export { AnthropicProvider } from './anthropic.js';
3
+ export { OpenRouterProvider } from './openrouter.js';
4
+ export { priceFor, computeCost } from './pricing.js';
5
+ import { LlmProvider, ProviderOptions } from './types.js';
6
+ /**
7
+ * Build a provider from options + environment. Nothing is baked in: the model always comes
8
+ * from the caller or LLM_MODEL, so an agent's proven configuration can't silently change by
9
+ * upgrading this package. Env surface:
10
+ * LLM_PROVIDER anthropic | openrouter (default anthropic)
11
+ * LLM_MODEL model id in the provider's namespace
12
+ * ANTHROPIC_API_KEY / OPENROUTER_API_KEY
13
+ * LLM_PRICE_IN_PER_MTOK / LLM_PRICE_OUT_PER_MTOK optional $ overrides for cost metering
14
+ */
15
+ export declare function createProvider(opts?: ProviderOptions, env?: Record<string, string | undefined>): LlmProvider;
package/dist/index.js ADDED
@@ -0,0 +1,31 @@
1
+ export * from './types.js';
2
+ export { AnthropicProvider } from './anthropic.js';
3
+ export { OpenRouterProvider } from './openrouter.js';
4
+ export { priceFor, computeCost } from './pricing.js';
5
+ import { LlmError } from './types.js';
6
+ import { AnthropicProvider } from './anthropic.js';
7
+ import { OpenRouterProvider } from './openrouter.js';
8
+ /**
9
+ * Build a provider from options + environment. Nothing is baked in: the model always comes
10
+ * from the caller or LLM_MODEL, so an agent's proven configuration can't silently change by
11
+ * upgrading this package. Env surface:
12
+ * LLM_PROVIDER anthropic | openrouter (default anthropic)
13
+ * LLM_MODEL model id in the provider's namespace
14
+ * ANTHROPIC_API_KEY / OPENROUTER_API_KEY
15
+ * LLM_PRICE_IN_PER_MTOK / LLM_PRICE_OUT_PER_MTOK optional $ overrides for cost metering
16
+ */
17
+ export function createProvider(opts = {}, env = process.env) {
18
+ const provider = (opts.provider ?? env.LLM_PROVIDER ?? 'anthropic');
19
+ const model = opts.model ?? env.LLM_MODEL ?? '';
20
+ const override = {
21
+ in: opts.priceInPerMtok ?? (env.LLM_PRICE_IN_PER_MTOK ? Number(env.LLM_PRICE_IN_PER_MTOK) : undefined),
22
+ out: opts.priceOutPerMtok ?? (env.LLM_PRICE_OUT_PER_MTOK ? Number(env.LLM_PRICE_OUT_PER_MTOK) : undefined),
23
+ };
24
+ if (provider === 'anthropic') {
25
+ return new AnthropicProvider(opts.apiKey ?? env.ANTHROPIC_API_KEY ?? '', model, override, opts.timeoutMs);
26
+ }
27
+ if (provider === 'openrouter') {
28
+ return new OpenRouterProvider(opts.apiKey ?? env.OPENROUTER_API_KEY ?? '', model, override, opts.timeoutMs);
29
+ }
30
+ throw new LlmError(`unknown LLM_PROVIDER: ${provider}`);
31
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * OpenRouterProvider — the same forced-JSON contract over OpenAI-style function calling.
3
+ * Primary path: tools + tool_choice (supported by the models we run, e.g. deepseek-chat).
4
+ * Fallback (one retry, only when the provider rejects tools): strict-JSON instruction +
5
+ * first-{...} extraction — so a model without function calling still satisfies the contract
6
+ * or throws a parse error (status undefined → caller's parse-only retry applies).
7
+ *
8
+ * Prompt caching: OpenRouter/deepseek cache automatically on repeated prefixes — there is no
9
+ * cache_control marker to send; `cacheSystem` is accepted and ignored. Cached prompt tokens,
10
+ * when reported (usage.prompt_tokens_details.cached_tokens), are billed at the read discount.
11
+ */
12
+ import { JsonCallRequest, JsonCallResult, LlmProvider } from './types.js';
13
+ import { Price } from './pricing.js';
14
+ export declare class OpenRouterProvider implements LlmProvider {
15
+ private apiKey;
16
+ readonly model: string;
17
+ private priceOverride?;
18
+ private timeoutMs;
19
+ readonly id: "openrouter";
20
+ constructor(apiKey: string, model: string, priceOverride?: Partial<Price> | undefined, timeoutMs?: number);
21
+ private post;
22
+ jsonCall(req: JsonCallRequest): Promise<JsonCallResult>;
23
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * OpenRouterProvider — the same forced-JSON contract over OpenAI-style function calling.
3
+ * Primary path: tools + tool_choice (supported by the models we run, e.g. deepseek-chat).
4
+ * Fallback (one retry, only when the provider rejects tools): strict-JSON instruction +
5
+ * first-{...} extraction — so a model without function calling still satisfies the contract
6
+ * or throws a parse error (status undefined → caller's parse-only retry applies).
7
+ *
8
+ * Prompt caching: OpenRouter/deepseek cache automatically on repeated prefixes — there is no
9
+ * cache_control marker to send; `cacheSystem` is accepted and ignored. Cached prompt tokens,
10
+ * when reported (usage.prompt_tokens_details.cached_tokens), are billed at the read discount.
11
+ */
12
+ import { LlmError } from './types.js';
13
+ import { computeCost, priceFor } from './pricing.js';
14
+ const API = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
15
+ export class OpenRouterProvider {
16
+ apiKey;
17
+ model;
18
+ priceOverride;
19
+ timeoutMs;
20
+ id = 'openrouter';
21
+ constructor(apiKey, model, priceOverride, timeoutMs = 60_000) {
22
+ this.apiKey = apiKey;
23
+ this.model = model;
24
+ this.priceOverride = priceOverride;
25
+ this.timeoutMs = timeoutMs;
26
+ if (!apiKey)
27
+ throw new LlmError('OpenRouterProvider: missing API key');
28
+ if (!model)
29
+ throw new LlmError('OpenRouterProvider: missing model');
30
+ }
31
+ async post(body) {
32
+ let res;
33
+ try {
34
+ res = await fetch(`${API}/chat/completions`, {
35
+ method: 'POST',
36
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${this.apiKey}` },
37
+ body: JSON.stringify(body),
38
+ signal: AbortSignal.timeout(this.timeoutMs),
39
+ });
40
+ }
41
+ catch (e) {
42
+ throw new LlmError(`openrouter transport: ${e?.message || e}`);
43
+ }
44
+ if (!res.ok) {
45
+ const detail = await res.text().catch(() => '');
46
+ throw new LlmError(`openrouter ${res.status}: ${detail.slice(0, 300)}`, res.status);
47
+ }
48
+ return res.json();
49
+ }
50
+ async jsonCall(req) {
51
+ const base = {
52
+ model: this.model,
53
+ max_tokens: req.maxTokens ?? 512,
54
+ temperature: 0,
55
+ };
56
+ const messages = [
57
+ { role: 'system', content: req.system },
58
+ { role: 'user', content: req.user },
59
+ ];
60
+ let object = null;
61
+ let d;
62
+ try {
63
+ d = await this.post({
64
+ ...base,
65
+ messages,
66
+ tools: [{ type: 'function', function: { name: req.tool.name, description: req.tool.description ?? '', parameters: req.tool.schema } }],
67
+ tool_choice: { type: 'function', function: { name: req.tool.name } },
68
+ });
69
+ const call = d.choices?.[0]?.message?.tool_calls?.[0];
70
+ if (call?.function?.arguments) {
71
+ try {
72
+ object = JSON.parse(call.function.arguments);
73
+ }
74
+ catch { /* falls through to parse error below */ }
75
+ }
76
+ if (!object)
77
+ throw new LlmError('no tool_call in response');
78
+ }
79
+ catch (e) {
80
+ // Only a provider-side "tools unsupported" 4xx earns the fallback; transport/5xx/parse rethrow.
81
+ const toolRejected = e?.status != null && e.status < 500 && /tool|function/i.test(String(e?.message));
82
+ if (!toolRejected)
83
+ throw e;
84
+ d = await this.post({
85
+ ...base,
86
+ response_format: { type: 'json_object' },
87
+ messages: [
88
+ { role: 'system', content: `${req.system}\n\nReply ONLY with a JSON object matching this schema (no prose): ${JSON.stringify(req.tool.schema)}` },
89
+ { role: 'user', content: req.user },
90
+ ],
91
+ });
92
+ const text = d.choices?.[0]?.message?.content ?? '';
93
+ const m = String(text).match(/\{[\s\S]*\}/);
94
+ if (!m)
95
+ throw new LlmError('no JSON object in fallback response');
96
+ try {
97
+ object = JSON.parse(m[0]);
98
+ }
99
+ catch {
100
+ throw new LlmError('unparseable JSON in fallback response');
101
+ }
102
+ }
103
+ const u = d.usage ?? {};
104
+ const promptTok = u.prompt_tokens ?? 0;
105
+ const cached = u.prompt_tokens_details?.cached_tokens ?? 0;
106
+ const outTok = u.completion_tokens ?? 0;
107
+ const price = priceFor('openrouter', this.model, this.priceOverride);
108
+ return {
109
+ object: object,
110
+ raw: JSON.stringify(object),
111
+ usage: {
112
+ inputTokens: promptTok,
113
+ outputTokens: outTok,
114
+ cacheReadTokens: cached,
115
+ cacheWriteTokens: 0,
116
+ costUsd: computeCost(price, promptTok - cached, outTok, cached, 0),
117
+ },
118
+ provider: this.id,
119
+ model: this.model,
120
+ };
121
+ }
122
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Cache-aware per-call cost. USD per MILLION tokens by model family, mirroring the
3
+ * agents' proven gate-cost meter (crypto-agent src/entry/gate-cost.ts): Anthropic
4
+ * cache READS bill at 0.1×, cache WRITES at 1.25×. OpenRouter prices vary per model —
5
+ * a small table covers the ones we run, and callers/env can override exactly
6
+ * (LLM_PRICE_IN_PER_MTOK / LLM_PRICE_OUT_PER_MTOK) when a different model is chosen.
7
+ * Unknown models fall back CONSERVATIVE (over-estimate) so a budget meter errs safe.
8
+ */
9
+ export interface Price {
10
+ in: number;
11
+ out: number;
12
+ }
13
+ export declare function priceFor(provider: 'anthropic' | 'openrouter', model: string, override?: Partial<Price>): Price;
14
+ export declare function computeCost(p: Price, inputTokens: number, outputTokens: number, cacheReadTokens?: number, cacheWriteTokens?: number): number;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Cache-aware per-call cost. USD per MILLION tokens by model family, mirroring the
3
+ * agents' proven gate-cost meter (crypto-agent src/entry/gate-cost.ts): Anthropic
4
+ * cache READS bill at 0.1×, cache WRITES at 1.25×. OpenRouter prices vary per model —
5
+ * a small table covers the ones we run, and callers/env can override exactly
6
+ * (LLM_PRICE_IN_PER_MTOK / LLM_PRICE_OUT_PER_MTOK) when a different model is chosen.
7
+ * Unknown models fall back CONSERVATIVE (over-estimate) so a budget meter errs safe.
8
+ */
9
+ const ANTHROPIC = {
10
+ haiku: { in: 1, out: 5 },
11
+ sonnet: { in: 3, out: 15 },
12
+ opus: { in: 15, out: 75 },
13
+ };
14
+ const OPENROUTER = {
15
+ 'deepseek': { in: 0.3, out: 1.2 },
16
+ 'llama-3.3-70b': { in: 0.2, out: 0.6 },
17
+ 'qwen': { in: 0.3, out: 1.2 },
18
+ };
19
+ export function priceFor(provider, model, override) {
20
+ const table = provider === 'anthropic' ? ANTHROPIC : OPENROUTER;
21
+ const key = Object.keys(table).find((k) => model.includes(k));
22
+ const base = key ? table[key] : provider === 'anthropic' ? ANTHROPIC.sonnet : { in: 1, out: 2 }; // conservative fallback
23
+ return { in: override?.in ?? base.in, out: override?.out ?? base.out };
24
+ }
25
+ export function computeCost(p, inputTokens, outputTokens, cacheReadTokens = 0, cacheWriteTokens = 0) {
26
+ return ((inputTokens / 1e6) * p.in +
27
+ (cacheReadTokens / 1e6) * p.in * 0.1 +
28
+ (cacheWriteTokens / 1e6) * p.in * 1.25 +
29
+ (outputTokens / 1e6) * p.out);
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ import { test, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { AnthropicProvider } from '../anthropic.js';
4
+ import { OpenRouterProvider } from '../openrouter.js';
5
+ import { createProvider } from '../index.js';
6
+ import { computeCost, priceFor } from '../pricing.js';
7
+ const realFetch = globalThis.fetch;
8
+ let calls = [];
9
+ function mockFetch(sequence) {
10
+ let i = 0;
11
+ globalThis.fetch = (async (url, init) => {
12
+ calls.push({ url: String(url), body: JSON.parse(init.body) });
13
+ const step = sequence[Math.min(i++, sequence.length - 1)];
14
+ return {
15
+ ok: step.status >= 200 && step.status < 300,
16
+ status: step.status,
17
+ json: async () => step.json,
18
+ text: async () => step.text ?? JSON.stringify(step.json ?? {}),
19
+ };
20
+ });
21
+ }
22
+ beforeEach(() => { calls = []; });
23
+ afterEach(() => { globalThis.fetch = realFetch; });
24
+ const TOOL = { name: 'record_verdict', schema: { type: 'object', properties: { take: { type: 'boolean' } }, required: ['take'] } };
25
+ const REQ = { system: 'rulebook', user: 'candidate', tool: TOOL };
26
+ test('anthropic: forced tool-use parses + cache-aware cost + cached system block', async () => {
27
+ mockFetch([{
28
+ status: 200,
29
+ json: {
30
+ content: [{ type: 'tool_use', input: { take: true, reason: 'ok', confidence: 0.7 } }],
31
+ usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 2000, cache_creation_input_tokens: 0 },
32
+ },
33
+ }]);
34
+ const p = new AnthropicProvider('k', 'claude-sonnet-5');
35
+ const r = await p.jsonCall(REQ);
36
+ assert.equal(r.object.take, true);
37
+ assert.equal(r.usage.inputTokens, 2100); // fresh + cache read
38
+ // sonnet: 100/1e6*3 + 2000/1e6*3*0.1 + 50/1e6*15
39
+ assert.ok(Math.abs(r.usage.costUsd - (0.0003 + 0.0006 + 0.00075)) < 1e-9);
40
+ const body = calls[0].body;
41
+ assert.equal(body.tool_choice.name, 'record_verdict');
42
+ assert.equal(body.system[0].cache_control.type, 'ephemeral');
43
+ });
44
+ test('anthropic: HTTP error carries .status; missing tool_use does not', async () => {
45
+ mockFetch([{ status: 429, text: 'rate limited' }]);
46
+ const p = new AnthropicProvider('k', 'claude-haiku-4-5');
47
+ await assert.rejects(p.jsonCall(REQ), (e) => e.status === 429);
48
+ mockFetch([{ status: 200, json: { content: [], usage: {} } }]);
49
+ await assert.rejects(p.jsonCall(REQ), (e) => e.status == null && /no tool_use/.test(e.message));
50
+ });
51
+ test('openrouter: function-calling path parses + cost with cached tokens', async () => {
52
+ mockFetch([{
53
+ status: 200,
54
+ json: {
55
+ choices: [{ message: { tool_calls: [{ function: { name: 'record_verdict', arguments: '{"take":false}' } }] } }],
56
+ usage: { prompt_tokens: 1000, completion_tokens: 100, prompt_tokens_details: { cached_tokens: 600 } },
57
+ },
58
+ }]);
59
+ const p = new OpenRouterProvider('k', 'deepseek/deepseek-chat');
60
+ const r = await p.jsonCall(REQ);
61
+ assert.equal(r.object.take, false);
62
+ assert.equal(r.usage.cacheReadTokens, 600);
63
+ // deepseek 0.3/1.2: fresh 400 in + 600 cached at 0.1x + 100 out
64
+ const want = (400 / 1e6) * 0.3 + (600 / 1e6) * 0.3 * 0.1 + (100 / 1e6) * 1.2;
65
+ assert.ok(Math.abs(r.usage.costUsd - want) < 1e-12);
66
+ assert.equal(calls[0].body.tool_choice.function.name, 'record_verdict');
67
+ });
68
+ test('openrouter: tools-rejected 400 falls back to json_object extraction', async () => {
69
+ mockFetch([
70
+ { status: 400, text: 'this model does not support tool use' },
71
+ { status: 200, json: { choices: [{ message: { content: 'sure: {"take":true} done' } }], usage: { prompt_tokens: 10, completion_tokens: 5 } } },
72
+ ]);
73
+ const p = new OpenRouterProvider('k', 'some/no-tools-model');
74
+ const r = await p.jsonCall(REQ);
75
+ assert.equal(r.object.take, true);
76
+ assert.equal(calls.length, 2);
77
+ assert.equal(calls[1].body.response_format.type, 'json_object');
78
+ });
79
+ test('openrouter: 5xx does NOT trigger fallback and keeps .status', async () => {
80
+ mockFetch([{ status: 503, text: 'overloaded' }]);
81
+ const p = new OpenRouterProvider('k', 'deepseek/deepseek-chat');
82
+ await assert.rejects(p.jsonCall(REQ), (e) => e.status === 503);
83
+ assert.equal(calls.length, 1);
84
+ });
85
+ test('factory: env selection + price override + unknown-model conservative fallback', () => {
86
+ const a = createProvider({}, { LLM_PROVIDER: 'anthropic', LLM_MODEL: 'claude-sonnet-5', ANTHROPIC_API_KEY: 'k' });
87
+ assert.equal(a.id, 'anthropic');
88
+ const o = createProvider({}, { LLM_PROVIDER: 'openrouter', LLM_MODEL: 'deepseek/deepseek-chat', OPENROUTER_API_KEY: 'k' });
89
+ assert.equal(o.id, 'openrouter');
90
+ assert.deepEqual(priceFor('openrouter', 'mystery/model'), { in: 1, out: 2 });
91
+ assert.deepEqual(priceFor('openrouter', 'x', { in: 0.5, out: 0.9 }), { in: 0.5, out: 0.9 });
92
+ assert.ok(computeCost({ in: 3, out: 15 }, 1e6, 0, 0, 1e6) === 3 + 3.75); // write surcharge 1.25x
93
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @stratchai/llm — provider-agnostic LLM adapter for the stratchai agents.
3
+ *
4
+ * One capability, done carefully: a FORCED-JSON call ("give me an object matching this schema,
5
+ * or throw") with cache-aware per-call cost accounting. Free-text JSON is not offered — it
6
+ * empties/mis-formats in bursts on some models (~34% observed live on the crypto gate,
7
+ * 2026-08-04), so schema-forced structured output is the only path this package exposes.
8
+ *
9
+ * Error contract (load-bearing): transport/HTTP errors THROW with `.status` set to the HTTP
10
+ * code; parse/shape errors THROW with `.status` undefined. Callers that retry only on
11
+ * `e.status == null` (parse-only retry — the agents' pattern) keep working unchanged.
12
+ */
13
+ /** JSON-schema-shaped tool the model MUST answer through. */
14
+ export interface JsonTool {
15
+ name: string;
16
+ description?: string;
17
+ /** Standard JSON schema object: { type:'object', properties, required } */
18
+ schema: Record<string, unknown>;
19
+ }
20
+ export interface JsonCallRequest {
21
+ /** Static rulebook — byte-identical across calls so provider prompt-caching engages. */
22
+ system: string;
23
+ /** Per-call content (the candidate). */
24
+ user: string;
25
+ tool: JsonTool;
26
+ /** Default 512. */
27
+ maxTokens?: number;
28
+ /** Mark the system block cacheable where the provider supports it (default true). */
29
+ cacheSystem?: boolean;
30
+ }
31
+ export interface Usage {
32
+ /** Total input volume seen by the provider (fresh + cache read + cache write). */
33
+ inputTokens: number;
34
+ outputTokens: number;
35
+ cacheReadTokens: number;
36
+ cacheWriteTokens: number;
37
+ /** Cache-aware USD for this call (reads discounted, writes surcharged where applicable). */
38
+ costUsd: number;
39
+ }
40
+ export interface JsonCallResult {
41
+ /** The schema-shaped object the model produced. Never null — a missing object throws. */
42
+ object: Record<string, unknown>;
43
+ /** JSON.stringify(object) — logging parity with the previous free-text era. */
44
+ raw: string;
45
+ usage: Usage;
46
+ provider: ProviderId;
47
+ model: string;
48
+ }
49
+ export type ProviderId = 'anthropic' | 'openrouter';
50
+ export interface LlmProvider {
51
+ readonly id: ProviderId;
52
+ readonly model: string;
53
+ jsonCall(req: JsonCallRequest): Promise<JsonCallResult>;
54
+ }
55
+ export interface ProviderOptions {
56
+ /** 'anthropic' | 'openrouter'. Default: env LLM_PROVIDER, else 'anthropic'. */
57
+ provider?: ProviderId;
58
+ /** Model id in the provider's namespace. Default: env LLM_MODEL (no baked-in product default). */
59
+ model?: string;
60
+ /** Default: env ANTHROPIC_API_KEY / OPENROUTER_API_KEY per provider. */
61
+ apiKey?: string;
62
+ /** USD per MILLION tokens overrides (OpenRouter models vary; env LLM_PRICE_IN_PER_MTOK / LLM_PRICE_OUT_PER_MTOK). */
63
+ priceInPerMtok?: number;
64
+ priceOutPerMtok?: number;
65
+ /** Request timeout ms (default 60000). */
66
+ timeoutMs?: number;
67
+ }
68
+ /** Error thrown by providers; `status` present only for HTTP-level failures. */
69
+ export declare class LlmError extends Error {
70
+ status?: number;
71
+ constructor(message: string, status?: number);
72
+ }
package/dist/types.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @stratchai/llm — provider-agnostic LLM adapter for the stratchai agents.
3
+ *
4
+ * One capability, done carefully: a FORCED-JSON call ("give me an object matching this schema,
5
+ * or throw") with cache-aware per-call cost accounting. Free-text JSON is not offered — it
6
+ * empties/mis-formats in bursts on some models (~34% observed live on the crypto gate,
7
+ * 2026-08-04), so schema-forced structured output is the only path this package exposes.
8
+ *
9
+ * Error contract (load-bearing): transport/HTTP errors THROW with `.status` set to the HTTP
10
+ * code; parse/shape errors THROW with `.status` undefined. Callers that retry only on
11
+ * `e.status == null` (parse-only retry — the agents' pattern) keep working unchanged.
12
+ */
13
+ /** Error thrown by providers; `status` present only for HTTP-level failures. */
14
+ export class LlmError extends Error {
15
+ status;
16
+ constructor(message, status) {
17
+ super(message);
18
+ this.name = 'LlmError';
19
+ if (status != null)
20
+ this.status = status;
21
+ }
22
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@stratchai/llm",
3
+ "version": "0.1.0",
4
+ "description": "Provider-agnostic forced-JSON LLM adapter for stratchai trading agents — Anthropic (tool-use + prompt caching) and OpenRouter (function calling) behind one interface, with cache-aware per-call cost accounting.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "files": ["dist", "README.md", "LICENSE"],
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "test": "npm run build && node --test dist/test/",
13
+ "prepublishOnly": "npm test"
14
+ },
15
+ "repository": { "type": "git", "url": "git+https://github.com/stratchai/llm.git" },
16
+ "bugs": { "url": "https://github.com/stratchai/llm/issues" },
17
+ "homepage": "https://github.com/stratchai/llm#readme",
18
+ "keywords": ["llm", "anthropic", "openrouter", "structured-output", "trading"],
19
+ "engines": { "node": ">=18" },
20
+ "devDependencies": { "@types/node": "^20.14.0", "typescript": "^5.5.0" }
21
+ }