@latimer-woods-tech/llm 0.2.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/CHANGELOG.md +16 -0
- package/README.md +3 -0
- package/dist/index.d.mts +87 -0
- package/dist/index.mjs +235 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +41 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.2.0] - 2026-04-29
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Multi-provider LLM completion chain with Anthropic primary, Grok fallback, and Groq tertiary fallback.
|
|
7
|
+
- Streaming support through the Anthropic-compatible provider path.
|
|
8
|
+
- `withSystem()` helper for consistently applying system prompts at call sites.
|
|
9
|
+
- Quality-gated package build with lint, typecheck, coverage, and ESM output.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Restored `@latimer-woods-tech/errors` and `@latimer-woods-tech/logger` as runtime dependencies so published consumers resolve package imports correctly.
|
|
13
|
+
|
|
14
|
+
### Verification
|
|
15
|
+
- `npm install` regenerated the package lock and ran the package prepublish gate on Apr 29, 2026.
|
|
16
|
+
- `npm run lint`, `npm run typecheck`, `npm test -- --coverage`, and `npm run build` passed during the prepublish gate.
|
package/README.md
ADDED
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { FactoryResponse } from '@latimer-woods-tech/errors';
|
|
2
|
+
import { Logger } from '@latimer-woods-tech/logger';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Single chat message exchanged with an LLM provider.
|
|
6
|
+
*/
|
|
7
|
+
interface LLMMessage {
|
|
8
|
+
role: 'user' | 'assistant' | 'system';
|
|
9
|
+
content: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Options that influence LLM completion behaviour.
|
|
13
|
+
*/
|
|
14
|
+
interface LLMOptions {
|
|
15
|
+
model?: string;
|
|
16
|
+
maxTokens?: number;
|
|
17
|
+
temperature?: number;
|
|
18
|
+
system?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Provider that produced an LLM response.
|
|
22
|
+
*/
|
|
23
|
+
type LLMProvider = 'anthropic' | 'grok' | 'groq';
|
|
24
|
+
/**
|
|
25
|
+
* Result returned by a successful completion.
|
|
26
|
+
*/
|
|
27
|
+
interface LLMResult {
|
|
28
|
+
content: string;
|
|
29
|
+
provider: LLMProvider;
|
|
30
|
+
tokens: {
|
|
31
|
+
input: number;
|
|
32
|
+
output: number;
|
|
33
|
+
};
|
|
34
|
+
latency: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Environment bindings required by {@link complete}.
|
|
38
|
+
*/
|
|
39
|
+
interface LLMEnv {
|
|
40
|
+
ANTHROPIC_API_KEY: string;
|
|
41
|
+
GROK_API_KEY: string;
|
|
42
|
+
GROQ_API_KEY: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Optional dependencies for {@link complete}.
|
|
46
|
+
*/
|
|
47
|
+
interface LLMDeps {
|
|
48
|
+
fetch?: typeof fetch;
|
|
49
|
+
logger?: Logger;
|
|
50
|
+
now?: () => number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Runs a completion through the Anthropic → Grok → Groq failover chain.
|
|
54
|
+
*
|
|
55
|
+
* @param messages - Ordered chat history.
|
|
56
|
+
* @param env - API key bindings.
|
|
57
|
+
* @param opts - Optional model/parameters override.
|
|
58
|
+
* @param deps - Optional fetch/logger/clock injection (for testing).
|
|
59
|
+
* @returns A {@link FactoryResponse} carrying either an {@link LLMResult} or
|
|
60
|
+
* an `LLM_ALL_PROVIDERS_FAILED` error.
|
|
61
|
+
*/
|
|
62
|
+
declare function complete(messages: LLMMessage[], env: LLMEnv, opts?: LLMOptions, deps?: LLMDeps): Promise<FactoryResponse<LLMResult>>;
|
|
63
|
+
/**
|
|
64
|
+
* Streams a completion from Anthropic. No failover is performed for streaming
|
|
65
|
+
* responses; callers should fall back to {@link complete} on failure.
|
|
66
|
+
*
|
|
67
|
+
* @param messages - Ordered chat history.
|
|
68
|
+
* @param env - Anthropic API key binding.
|
|
69
|
+
* @param opts - Optional model/parameters override.
|
|
70
|
+
* @param deps - Optional fetch override (for testing).
|
|
71
|
+
* @returns The raw Anthropic streaming response body.
|
|
72
|
+
*/
|
|
73
|
+
declare function stream(messages: LLMMessage[], env: {
|
|
74
|
+
ANTHROPIC_API_KEY: string;
|
|
75
|
+
}, opts?: LLMOptions, deps?: {
|
|
76
|
+
fetch?: typeof fetch;
|
|
77
|
+
}): Promise<ReadableStream<Uint8Array>>;
|
|
78
|
+
/**
|
|
79
|
+
* Returns a {@link complete}-compatible function with a system prompt
|
|
80
|
+
* pre-bound, so callers can hand around a domain-specific shortcut.
|
|
81
|
+
*
|
|
82
|
+
* @param system - System prompt to prepend to every call.
|
|
83
|
+
* @returns A function that invokes {@link complete} with `system` injected.
|
|
84
|
+
*/
|
|
85
|
+
declare function withSystem(system: string): (messages: LLMMessage[], env: LLMEnv, opts?: LLMOptions, deps?: LLMDeps) => Promise<FactoryResponse<LLMResult>>;
|
|
86
|
+
|
|
87
|
+
export { type LLMDeps, type LLMEnv, type LLMMessage, type LLMOptions, type LLMProvider, type LLMResult, complete, stream, withSystem };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
ErrorCodes,
|
|
4
|
+
InternalError,
|
|
5
|
+
RateLimitError,
|
|
6
|
+
ValidationError
|
|
7
|
+
} from "@latimer-woods-tech/errors";
|
|
8
|
+
var DEFAULT_MAX_TOKENS = 1024;
|
|
9
|
+
var DEFAULT_TEMPERATURE = 0.7;
|
|
10
|
+
var DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514";
|
|
11
|
+
var DEFAULT_GROK_MODEL = "grok-3-fast";
|
|
12
|
+
var DEFAULT_GROQ_MODEL = "llama-3.3-70b-versatile";
|
|
13
|
+
function isFailover(status) {
|
|
14
|
+
return status === 429 || status >= 500;
|
|
15
|
+
}
|
|
16
|
+
function buildAnthropicBody(messages, opts, system) {
|
|
17
|
+
const filtered = messages.filter((m) => m.role !== "system");
|
|
18
|
+
const sys = system ?? messages.find((m) => m.role === "system")?.content;
|
|
19
|
+
const body = {
|
|
20
|
+
model: opts.model ?? DEFAULT_ANTHROPIC_MODEL,
|
|
21
|
+
max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
22
|
+
temperature: opts.temperature ?? DEFAULT_TEMPERATURE,
|
|
23
|
+
messages: filtered.map((m) => ({ role: m.role, content: m.content }))
|
|
24
|
+
};
|
|
25
|
+
if (sys) {
|
|
26
|
+
body.system = sys;
|
|
27
|
+
}
|
|
28
|
+
return JSON.stringify(body);
|
|
29
|
+
}
|
|
30
|
+
function buildOpenAIBody(model, messages, opts, system) {
|
|
31
|
+
const sys = system ?? messages.find((m) => m.role === "system")?.content;
|
|
32
|
+
const merged = [];
|
|
33
|
+
if (sys) {
|
|
34
|
+
merged.push({ role: "system", content: sys });
|
|
35
|
+
}
|
|
36
|
+
for (const m of messages) {
|
|
37
|
+
if (m.role !== "system") {
|
|
38
|
+
merged.push(m);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return JSON.stringify({
|
|
42
|
+
model,
|
|
43
|
+
max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
44
|
+
temperature: opts.temperature ?? DEFAULT_TEMPERATURE,
|
|
45
|
+
messages: merged
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function parseAnthropic(json) {
|
|
49
|
+
const r = json;
|
|
50
|
+
const text = r.content?.find((c) => c.type === "text")?.text ?? "";
|
|
51
|
+
return {
|
|
52
|
+
content: text,
|
|
53
|
+
input: r.usage?.input_tokens ?? 0,
|
|
54
|
+
output: r.usage?.output_tokens ?? 0
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function parseOpenAI(json) {
|
|
58
|
+
const r = json;
|
|
59
|
+
const text = r.choices?.[0]?.message?.content ?? "";
|
|
60
|
+
return {
|
|
61
|
+
content: text,
|
|
62
|
+
input: r.usage?.prompt_tokens ?? 0,
|
|
63
|
+
output: r.usage?.completion_tokens ?? 0
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async function callProvider(provider, request, fetchImpl) {
|
|
67
|
+
const response = await fetchImpl(request.url, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: request.headers,
|
|
70
|
+
body: request.body
|
|
71
|
+
});
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
const text = await response.text().catch(() => "");
|
|
74
|
+
const err = {
|
|
75
|
+
status: response.status,
|
|
76
|
+
message: `${provider} request failed (${String(response.status)}): ${text.slice(0, 200)}`
|
|
77
|
+
};
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
return await response.json();
|
|
81
|
+
}
|
|
82
|
+
function isProviderError(err) {
|
|
83
|
+
return typeof err === "object" && err !== null && typeof err.status === "number" && typeof err.message === "string";
|
|
84
|
+
}
|
|
85
|
+
async function complete(messages, env, opts = {}, deps = {}) {
|
|
86
|
+
if (messages.length === 0) {
|
|
87
|
+
throw new ValidationError("messages must not be empty");
|
|
88
|
+
}
|
|
89
|
+
const fetchImpl = deps.fetch ?? fetch;
|
|
90
|
+
const now = deps.now ?? (() => Date.now());
|
|
91
|
+
const logger = deps.logger;
|
|
92
|
+
const startedAt = now();
|
|
93
|
+
const attempts = [];
|
|
94
|
+
try {
|
|
95
|
+
const json = await callProvider(
|
|
96
|
+
"anthropic",
|
|
97
|
+
{
|
|
98
|
+
url: "https://api.anthropic.com/v1/messages",
|
|
99
|
+
headers: {
|
|
100
|
+
"content-type": "application/json",
|
|
101
|
+
"x-api-key": env.ANTHROPIC_API_KEY,
|
|
102
|
+
"anthropic-version": "2023-06-01"
|
|
103
|
+
},
|
|
104
|
+
body: buildAnthropicBody(messages, opts, opts.system)
|
|
105
|
+
},
|
|
106
|
+
fetchImpl
|
|
107
|
+
);
|
|
108
|
+
const parsed = parseAnthropic(json);
|
|
109
|
+
return {
|
|
110
|
+
data: {
|
|
111
|
+
content: parsed.content,
|
|
112
|
+
provider: "anthropic",
|
|
113
|
+
tokens: { input: parsed.input, output: parsed.output },
|
|
114
|
+
latency: now() - startedAt
|
|
115
|
+
},
|
|
116
|
+
error: null
|
|
117
|
+
};
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const status = isProviderError(err) ? err.status : 0;
|
|
120
|
+
const message = isProviderError(err) ? err.message : err.message;
|
|
121
|
+
attempts.push({ provider: "anthropic", status, message });
|
|
122
|
+
if (status !== 0 && !isFailover(status)) {
|
|
123
|
+
return providerErrorResponse(attempts, status === 429);
|
|
124
|
+
}
|
|
125
|
+
logger?.warn("llm.failover", { from: "anthropic", to: "grok", status, message });
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const json = await callProvider(
|
|
129
|
+
"grok",
|
|
130
|
+
{
|
|
131
|
+
url: "https://api.x.ai/v1/chat/completions",
|
|
132
|
+
headers: {
|
|
133
|
+
"content-type": "application/json",
|
|
134
|
+
authorization: `Bearer ${env.GROK_API_KEY}`
|
|
135
|
+
},
|
|
136
|
+
body: buildOpenAIBody(opts.model ?? DEFAULT_GROK_MODEL, messages, opts, opts.system)
|
|
137
|
+
},
|
|
138
|
+
fetchImpl
|
|
139
|
+
);
|
|
140
|
+
const parsed = parseOpenAI(json);
|
|
141
|
+
return {
|
|
142
|
+
data: {
|
|
143
|
+
content: parsed.content,
|
|
144
|
+
provider: "grok",
|
|
145
|
+
tokens: { input: parsed.input, output: parsed.output },
|
|
146
|
+
latency: now() - startedAt
|
|
147
|
+
},
|
|
148
|
+
error: null
|
|
149
|
+
};
|
|
150
|
+
} catch (err) {
|
|
151
|
+
const status = isProviderError(err) ? err.status : 0;
|
|
152
|
+
const message = isProviderError(err) ? err.message : err.message;
|
|
153
|
+
attempts.push({ provider: "grok", status, message });
|
|
154
|
+
logger?.warn("llm.failover", { from: "grok", to: "groq", status, message });
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const json = await callProvider(
|
|
158
|
+
"groq",
|
|
159
|
+
{
|
|
160
|
+
url: "https://api.groq.com/openai/v1/chat/completions",
|
|
161
|
+
headers: {
|
|
162
|
+
"content-type": "application/json",
|
|
163
|
+
authorization: `Bearer ${env.GROQ_API_KEY}`
|
|
164
|
+
},
|
|
165
|
+
body: buildOpenAIBody(opts.model ?? DEFAULT_GROQ_MODEL, messages, opts, opts.system)
|
|
166
|
+
},
|
|
167
|
+
fetchImpl
|
|
168
|
+
);
|
|
169
|
+
const parsed = parseOpenAI(json);
|
|
170
|
+
return {
|
|
171
|
+
data: {
|
|
172
|
+
content: parsed.content,
|
|
173
|
+
provider: "groq",
|
|
174
|
+
tokens: { input: parsed.input, output: parsed.output },
|
|
175
|
+
latency: now() - startedAt
|
|
176
|
+
},
|
|
177
|
+
error: null
|
|
178
|
+
};
|
|
179
|
+
} catch (err) {
|
|
180
|
+
const status = isProviderError(err) ? err.status : 0;
|
|
181
|
+
const message = isProviderError(err) ? err.message : err.message;
|
|
182
|
+
attempts.push({ provider: "groq", status, message });
|
|
183
|
+
logger?.error("llm.all_providers_failed", void 0, { attempts });
|
|
184
|
+
}
|
|
185
|
+
return providerErrorResponse(attempts, false);
|
|
186
|
+
}
|
|
187
|
+
function providerErrorResponse(attempts, rateLimited) {
|
|
188
|
+
const base = rateLimited ? new RateLimitError("LLM provider rate limited", { code: ErrorCodes.LLM_RATE_LIMITED, attempts }) : new InternalError("All LLM providers failed", {
|
|
189
|
+
code: ErrorCodes.LLM_ALL_PROVIDERS_FAILED,
|
|
190
|
+
attempts
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
data: null,
|
|
194
|
+
error: {
|
|
195
|
+
code: rateLimited ? ErrorCodes.LLM_RATE_LIMITED : ErrorCodes.LLM_ALL_PROVIDERS_FAILED,
|
|
196
|
+
message: base.message,
|
|
197
|
+
status: base.status,
|
|
198
|
+
retryable: base.retryable,
|
|
199
|
+
context: base.context
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
async function stream(messages, env, opts = {}, deps = {}) {
|
|
204
|
+
if (messages.length === 0) {
|
|
205
|
+
throw new ValidationError("messages must not be empty");
|
|
206
|
+
}
|
|
207
|
+
const fetchImpl = deps.fetch ?? fetch;
|
|
208
|
+
const body = JSON.parse(buildAnthropicBody(messages, opts, opts.system));
|
|
209
|
+
body.stream = true;
|
|
210
|
+
const response = await fetchImpl("https://api.anthropic.com/v1/messages", {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: {
|
|
213
|
+
"content-type": "application/json",
|
|
214
|
+
"x-api-key": env.ANTHROPIC_API_KEY,
|
|
215
|
+
"anthropic-version": "2023-06-01"
|
|
216
|
+
},
|
|
217
|
+
body: JSON.stringify(body)
|
|
218
|
+
});
|
|
219
|
+
if (!response.ok || !response.body) {
|
|
220
|
+
throw new InternalError("Anthropic stream failed", {
|
|
221
|
+
code: ErrorCodes.LLM_ALL_PROVIDERS_FAILED,
|
|
222
|
+
status: response.status
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return response.body;
|
|
226
|
+
}
|
|
227
|
+
function withSystem(system) {
|
|
228
|
+
return (messages, env, opts = {}, deps = {}) => complete(messages, env, { ...opts, system }, deps);
|
|
229
|
+
}
|
|
230
|
+
export {
|
|
231
|
+
complete,
|
|
232
|
+
stream,
|
|
233
|
+
withSystem
|
|
234
|
+
};
|
|
235
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["import {\n ErrorCodes,\n FactoryBaseError,\n InternalError,\n RateLimitError,\n ValidationError,\n type FactoryResponse,\n} from '@latimer-woods-tech/errors';\nimport type { Logger } from '@latimer-woods-tech/logger';\n\n/**\n * Single chat message exchanged with an LLM provider.\n */\nexport interface LLMMessage {\n role: 'user' | 'assistant' | 'system';\n content: string;\n}\n\n/**\n * Options that influence LLM completion behaviour.\n */\nexport interface LLMOptions {\n model?: string;\n maxTokens?: number;\n temperature?: number;\n system?: string;\n}\n\n/**\n * Provider that produced an LLM response.\n */\nexport type LLMProvider = 'anthropic' | 'grok' | 'groq';\n\n/**\n * Result returned by a successful completion.\n */\nexport interface LLMResult {\n content: string;\n provider: LLMProvider;\n tokens: { input: number; output: number };\n latency: number;\n}\n\n/**\n * Environment bindings required by {@link complete}.\n */\nexport interface LLMEnv {\n ANTHROPIC_API_KEY: string;\n GROK_API_KEY: string;\n GROQ_API_KEY: string;\n}\n\n/**\n * Optional dependencies for {@link complete}.\n */\nexport interface LLMDeps {\n fetch?: typeof fetch;\n logger?: Logger;\n now?: () => number;\n}\n\nconst DEFAULT_MAX_TOKENS = 1024;\nconst DEFAULT_TEMPERATURE = 0.7;\nconst DEFAULT_ANTHROPIC_MODEL = 'claude-sonnet-4-20250514';\nconst DEFAULT_GROK_MODEL = 'grok-3-fast';\nconst DEFAULT_GROQ_MODEL = 'llama-3.3-70b-versatile';\n\ninterface ProviderError {\n status: number;\n message: string;\n}\n\nfunction isFailover(status: number): boolean {\n return status === 429 || status >= 500;\n}\n\nfunction buildAnthropicBody(messages: LLMMessage[], opts: LLMOptions, system?: string): string {\n const filtered = messages.filter((m) => m.role !== 'system');\n const sys = system ?? messages.find((m) => m.role === 'system')?.content;\n const body: Record<string, unknown> = {\n model: opts.model ?? DEFAULT_ANTHROPIC_MODEL,\n max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,\n temperature: opts.temperature ?? DEFAULT_TEMPERATURE,\n messages: filtered.map((m) => ({ role: m.role, content: m.content })),\n };\n if (sys) {\n body.system = sys;\n }\n return JSON.stringify(body);\n}\n\nfunction buildOpenAIBody(\n model: string,\n messages: LLMMessage[],\n opts: LLMOptions,\n system?: string,\n): string {\n const sys = system ?? messages.find((m) => m.role === 'system')?.content;\n const merged: LLMMessage[] = [];\n if (sys) {\n merged.push({ role: 'system', content: sys });\n }\n for (const m of messages) {\n if (m.role !== 'system') {\n merged.push(m);\n }\n }\n return JSON.stringify({\n model,\n max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,\n temperature: opts.temperature ?? DEFAULT_TEMPERATURE,\n messages: merged,\n });\n}\n\ninterface AnthropicResponse {\n content?: Array<{ type: string; text?: string }>;\n usage?: { input_tokens?: number; output_tokens?: number };\n}\n\ninterface OpenAIResponse {\n choices?: Array<{ message?: { content?: string } }>;\n usage?: { prompt_tokens?: number; completion_tokens?: number };\n}\n\nfunction parseAnthropic(json: unknown): { content: string; input: number; output: number } {\n const r = json as AnthropicResponse;\n const text = r.content?.find((c) => c.type === 'text')?.text ?? '';\n return {\n content: text,\n input: r.usage?.input_tokens ?? 0,\n output: r.usage?.output_tokens ?? 0,\n };\n}\n\nfunction parseOpenAI(json: unknown): { content: string; input: number; output: number } {\n const r = json as OpenAIResponse;\n const text = r.choices?.[0]?.message?.content ?? '';\n return {\n content: text,\n input: r.usage?.prompt_tokens ?? 0,\n output: r.usage?.completion_tokens ?? 0,\n };\n}\n\nasync function callProvider(\n provider: LLMProvider,\n request: { url: string; headers: Record<string, string>; body: string },\n fetchImpl: typeof fetch,\n): Promise<unknown> {\n const response = await fetchImpl(request.url, {\n method: 'POST',\n headers: request.headers,\n body: request.body,\n });\n if (!response.ok) {\n const text = await response.text().catch(() => '');\n const err: ProviderError = {\n status: response.status,\n message: `${provider} request failed (${String(response.status)}): ${text.slice(0, 200)}`,\n };\n throw err;\n }\n return (await response.json()) as unknown;\n}\n\nfunction isProviderError(err: unknown): err is ProviderError {\n return (\n typeof err === 'object' &&\n err !== null &&\n typeof (err as { status?: unknown }).status === 'number' &&\n typeof (err as { message?: unknown }).message === 'string'\n );\n}\n\n/**\n * Runs a completion through the Anthropic → Grok → Groq failover chain.\n *\n * @param messages - Ordered chat history.\n * @param env - API key bindings.\n * @param opts - Optional model/parameters override.\n * @param deps - Optional fetch/logger/clock injection (for testing).\n * @returns A {@link FactoryResponse} carrying either an {@link LLMResult} or\n * an `LLM_ALL_PROVIDERS_FAILED` error.\n */\nexport async function complete(\n messages: LLMMessage[],\n env: LLMEnv,\n opts: LLMOptions = {},\n deps: LLMDeps = {},\n): Promise<FactoryResponse<LLMResult>> {\n if (messages.length === 0) {\n throw new ValidationError('messages must not be empty');\n }\n const fetchImpl = deps.fetch ?? fetch;\n const now = deps.now ?? (() => Date.now());\n const logger = deps.logger;\n const startedAt = now();\n\n const attempts: Array<{ provider: LLMProvider; status?: number; message: string }> = [];\n\n // Anthropic\n try {\n const json = await callProvider(\n 'anthropic',\n {\n url: 'https://api.anthropic.com/v1/messages',\n headers: {\n 'content-type': 'application/json',\n 'x-api-key': env.ANTHROPIC_API_KEY,\n 'anthropic-version': '2023-06-01',\n },\n body: buildAnthropicBody(messages, opts, opts.system),\n },\n fetchImpl,\n );\n const parsed = parseAnthropic(json);\n return {\n data: {\n content: parsed.content,\n provider: 'anthropic',\n tokens: { input: parsed.input, output: parsed.output },\n latency: now() - startedAt,\n },\n error: null,\n };\n } catch (err) {\n const status = isProviderError(err) ? err.status : 0;\n const message = isProviderError(err) ? err.message : (err as Error).message;\n attempts.push({ provider: 'anthropic', status, message });\n if (status !== 0 && !isFailover(status)) {\n return providerErrorResponse(attempts, status === 429);\n }\n logger?.warn('llm.failover', { from: 'anthropic', to: 'grok', status, message });\n }\n\n // Grok\n try {\n const json = await callProvider(\n 'grok',\n {\n url: 'https://api.x.ai/v1/chat/completions',\n headers: {\n 'content-type': 'application/json',\n authorization: `Bearer ${env.GROK_API_KEY}`,\n },\n body: buildOpenAIBody(opts.model ?? DEFAULT_GROK_MODEL, messages, opts, opts.system),\n },\n fetchImpl,\n );\n const parsed = parseOpenAI(json);\n return {\n data: {\n content: parsed.content,\n provider: 'grok',\n tokens: { input: parsed.input, output: parsed.output },\n latency: now() - startedAt,\n },\n error: null,\n };\n } catch (err) {\n const status = isProviderError(err) ? err.status : 0;\n const message = isProviderError(err) ? err.message : (err as Error).message;\n attempts.push({ provider: 'grok', status, message });\n logger?.warn('llm.failover', { from: 'grok', to: 'groq', status, message });\n }\n\n // Groq\n try {\n const json = await callProvider(\n 'groq',\n {\n url: 'https://api.groq.com/openai/v1/chat/completions',\n headers: {\n 'content-type': 'application/json',\n authorization: `Bearer ${env.GROQ_API_KEY}`,\n },\n body: buildOpenAIBody(opts.model ?? DEFAULT_GROQ_MODEL, messages, opts, opts.system),\n },\n fetchImpl,\n );\n const parsed = parseOpenAI(json);\n return {\n data: {\n content: parsed.content,\n provider: 'groq',\n tokens: { input: parsed.input, output: parsed.output },\n latency: now() - startedAt,\n },\n error: null,\n };\n } catch (err) {\n const status = isProviderError(err) ? err.status : 0;\n const message = isProviderError(err) ? err.message : (err as Error).message;\n attempts.push({ provider: 'groq', status, message });\n logger?.error('llm.all_providers_failed', undefined, { attempts });\n }\n\n return providerErrorResponse(attempts, false);\n}\n\nfunction providerErrorResponse(\n attempts: Array<{ provider: LLMProvider; status?: number; message: string }>,\n rateLimited: boolean,\n): FactoryResponse<LLMResult> {\n const base: FactoryBaseError = rateLimited\n ? new RateLimitError('LLM provider rate limited', { code: ErrorCodes.LLM_RATE_LIMITED, attempts })\n : new InternalError('All LLM providers failed', {\n code: ErrorCodes.LLM_ALL_PROVIDERS_FAILED,\n attempts,\n });\n return {\n data: null,\n error: {\n code: rateLimited ? ErrorCodes.LLM_RATE_LIMITED : ErrorCodes.LLM_ALL_PROVIDERS_FAILED,\n message: base.message,\n status: base.status,\n retryable: base.retryable,\n context: base.context,\n },\n };\n}\n\n/**\n * Streams a completion from Anthropic. No failover is performed for streaming\n * responses; callers should fall back to {@link complete} on failure.\n *\n * @param messages - Ordered chat history.\n * @param env - Anthropic API key binding.\n * @param opts - Optional model/parameters override.\n * @param deps - Optional fetch override (for testing).\n * @returns The raw Anthropic streaming response body.\n */\nexport async function stream(\n messages: LLMMessage[],\n env: { ANTHROPIC_API_KEY: string },\n opts: LLMOptions = {},\n deps: { fetch?: typeof fetch } = {},\n): Promise<ReadableStream<Uint8Array>> {\n if (messages.length === 0) {\n throw new ValidationError('messages must not be empty');\n }\n const fetchImpl = deps.fetch ?? fetch;\n const body = JSON.parse(buildAnthropicBody(messages, opts, opts.system)) as Record<string, unknown>;\n body.stream = true;\n const response = await fetchImpl('https://api.anthropic.com/v1/messages', {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n 'x-api-key': env.ANTHROPIC_API_KEY,\n 'anthropic-version': '2023-06-01',\n },\n body: JSON.stringify(body),\n });\n if (!response.ok || !response.body) {\n throw new InternalError('Anthropic stream failed', {\n code: ErrorCodes.LLM_ALL_PROVIDERS_FAILED,\n status: response.status,\n });\n }\n return response.body;\n}\n\n/**\n * Returns a {@link complete}-compatible function with a system prompt\n * pre-bound, so callers can hand around a domain-specific shortcut.\n *\n * @param system - System prompt to prepend to every call.\n * @returns A function that invokes {@link complete} with `system` injected.\n */\nexport function withSystem(\n system: string,\n): (\n messages: LLMMessage[],\n env: LLMEnv,\n opts?: LLMOptions,\n deps?: LLMDeps,\n) => Promise<FactoryResponse<LLMResult>> {\n return (messages, env, opts = {}, deps = {}) =>\n complete(messages, env, { ...opts, system }, deps);\n}"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@latimer-woods-tech/llm",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {},
|
|
6
|
+
"main": "./dist/index.mjs",
|
|
7
|
+
"module": "./dist/index.mjs",
|
|
8
|
+
"types": "./dist/index.d.mts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"types": "./dist/index.d.mts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"CHANGELOG.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsup src/index.ts --format esm --dts",
|
|
22
|
+
"test": "vitest run --coverage",
|
|
23
|
+
"lint": "eslint src --max-warnings 0",
|
|
24
|
+
"typecheck": "tsc --noEmit"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@latimer-woods-tech/errors": "^0.2.0",
|
|
28
|
+
"@latimer-woods-tech/logger": "^0.2.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@latimer-woods-tech/monitoring": "^0.2.0",
|
|
32
|
+
"@types/node": "^25.6.0",
|
|
33
|
+
"typescript": "^5.4.0",
|
|
34
|
+
"tsup": "^8.1.0",
|
|
35
|
+
"vitest": "^1.6.0",
|
|
36
|
+
"@vitest/coverage-v8": "^1.6.0",
|
|
37
|
+
"eslint": "^8.57.0",
|
|
38
|
+
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
39
|
+
"@typescript-eslint/parser": "^7.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|