@steve31415/baselib 2.4.3 → 3.0.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/README.md +5 -1
- package/dist/auth.d.ts +20 -7
- package/dist/auth.js +90 -16
- package/dist/llm/complete.d.ts +11 -0
- package/dist/llm/complete.js +145 -0
- package/dist/llm/index.d.ts +4 -0
- package/dist/llm/index.js +10 -0
- package/dist/llm/models.d.ts +54 -0
- package/dist/llm/models.js +166 -0
- package/dist/llm/providers/anthropic.d.ts +2 -0
- package/dist/llm/providers/anthropic.js +122 -0
- package/dist/llm/providers/gemini.d.ts +2 -0
- package/dist/llm/providers/gemini.js +128 -0
- package/dist/llm/providers/index.d.ts +2 -0
- package/dist/llm/providers/index.js +8 -0
- package/dist/llm/providers/openai.d.ts +2 -0
- package/dist/llm/providers/openai.js +183 -0
- package/dist/llm/types.d.ts +203 -0
- package/dist/llm/types.js +25 -0
- package/package.json +5 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { LLMError } from '../types.js';
|
|
2
|
+
/** Classify Anthropic's `stop_reason` (already lowercase from the API). */
|
|
3
|
+
function classifyFinish(stopReason) {
|
|
4
|
+
switch (stopReason) {
|
|
5
|
+
case 'refusal': return 'declined';
|
|
6
|
+
case 'max_tokens':
|
|
7
|
+
case 'model_context_window_exceeded': return 'truncated';
|
|
8
|
+
case 'end_turn':
|
|
9
|
+
case 'stop_sequence':
|
|
10
|
+
case 'tool_use':
|
|
11
|
+
case 'pause_turn': return 'complete';
|
|
12
|
+
default: return 'other';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export const anthropicProvider = {
|
|
16
|
+
name: 'anthropic',
|
|
17
|
+
async complete(request) {
|
|
18
|
+
const endpoint = 'https://api.anthropic.com/v1/messages';
|
|
19
|
+
request.logger?.info('llm.request', {
|
|
20
|
+
provider: 'anthropic', model: request.apiModelId, endpoint,
|
|
21
|
+
...(request.callSite ? { callSite: request.callSite } : {}),
|
|
22
|
+
});
|
|
23
|
+
const startTime = Date.now();
|
|
24
|
+
const body = {
|
|
25
|
+
model: request.apiModelId,
|
|
26
|
+
max_tokens: request.maxTokens,
|
|
27
|
+
messages: [{ role: 'user', content: request.prompt }],
|
|
28
|
+
};
|
|
29
|
+
// Some models (Opus 4.7+, Sonnet 5, Fable 5) reject `temperature`
|
|
30
|
+
// outright with a 400 — omit the field entirely rather than sending it.
|
|
31
|
+
if (request.supportsTemperature) {
|
|
32
|
+
body.temperature = request.temperature;
|
|
33
|
+
}
|
|
34
|
+
// Output-effort control (Sonnet 4.6+/Opus 4.6+/Sonnet 5/Opus 5): lower
|
|
35
|
+
// effort trades thoroughness for latency/cost. Only ever set here when the
|
|
36
|
+
// registry marks the model effort-capable (gated in complete.ts).
|
|
37
|
+
if (request.effort) {
|
|
38
|
+
body.output_config = { effort: request.effort };
|
|
39
|
+
}
|
|
40
|
+
if (request.systemPrompt) {
|
|
41
|
+
body.system = request.cacheSystemPrompt
|
|
42
|
+
? [{ type: 'text', text: request.systemPrompt, cache_control: { type: 'ephemeral' } }]
|
|
43
|
+
: request.systemPrompt;
|
|
44
|
+
}
|
|
45
|
+
// JSON schema: use tool_use to get structured output
|
|
46
|
+
if (request.jsonSchema) {
|
|
47
|
+
body.tools = [{
|
|
48
|
+
name: request.jsonSchema.name,
|
|
49
|
+
description: `Generate structured output matching the ${request.jsonSchema.name} schema`,
|
|
50
|
+
input_schema: request.jsonSchema.schema,
|
|
51
|
+
}];
|
|
52
|
+
body.tool_choice = { type: 'tool', name: request.jsonSchema.name };
|
|
53
|
+
}
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
const timer = setTimeout(() => controller.abort(), request.timeoutMs);
|
|
56
|
+
let response;
|
|
57
|
+
try {
|
|
58
|
+
response = await fetch(endpoint, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: {
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
'x-api-key': request.apiKey,
|
|
63
|
+
'anthropic-version': '2023-06-01',
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify(body),
|
|
66
|
+
signal: controller.signal,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const message = err instanceof Error && err.name === 'AbortError'
|
|
71
|
+
? `Anthropic API timeout after ${request.timeoutMs}ms`
|
|
72
|
+
: `Anthropic API network error: ${err instanceof Error ? err.message : String(err)}`;
|
|
73
|
+
throw new LLMError({
|
|
74
|
+
message,
|
|
75
|
+
provider: 'anthropic',
|
|
76
|
+
model: request.apiModelId,
|
|
77
|
+
isRetryable: true,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
}
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
const errorText = await response.text();
|
|
85
|
+
throw new LLMError({
|
|
86
|
+
message: `Anthropic API error: ${response.status} - ${errorText.slice(0, 500)}`,
|
|
87
|
+
provider: 'anthropic',
|
|
88
|
+
model: request.apiModelId,
|
|
89
|
+
statusCode: response.status,
|
|
90
|
+
isRetryable: response.status >= 500 || response.status === 429,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const data = await response.json();
|
|
94
|
+
const latencyMs = Date.now() - startTime;
|
|
95
|
+
const outputTokens = data.usage?.output_tokens ?? undefined;
|
|
96
|
+
const tokensUsed = (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0);
|
|
97
|
+
const cacheReadTokens = data.usage?.cache_read_input_tokens ?? undefined;
|
|
98
|
+
const cacheCreationTokens = data.usage?.cache_creation_input_tokens ?? undefined;
|
|
99
|
+
const finishReason = data.stop_reason ?? undefined;
|
|
100
|
+
let text;
|
|
101
|
+
let json;
|
|
102
|
+
if (request.jsonSchema) {
|
|
103
|
+
const toolUse = data.content?.find((c) => c.type === 'tool_use');
|
|
104
|
+
json = toolUse?.input;
|
|
105
|
+
text = json !== undefined ? JSON.stringify(json) : '';
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
text = data.content?.find((c) => c.type === 'text')?.text || '';
|
|
109
|
+
}
|
|
110
|
+
request.logger?.info('llm.response', {
|
|
111
|
+
provider: 'anthropic', model: request.apiModelId,
|
|
112
|
+
status: response.status, latencyMs, tokensUsed, outputTokens, finishReason,
|
|
113
|
+
...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}),
|
|
114
|
+
...(cacheCreationTokens !== undefined ? { cacheCreationTokens } : {}),
|
|
115
|
+
...(request.callSite ? { callSite: request.callSite } : {}),
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
text, json, tokensUsed, outputTokens, cacheReadTokens, cacheCreationTokens,
|
|
119
|
+
finishReason, finishCategory: classifyFinish(finishReason), latencyMs,
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { LLMError } from '../types.js';
|
|
2
|
+
/** Gemini `finishReason` enum values that mean the model was content-blocked. */
|
|
3
|
+
const GEMINI_DECLINE_REASONS = new Set([
|
|
4
|
+
'safety', 'recitation', 'prohibited_content', 'blocklist', 'spii', 'image_safety',
|
|
5
|
+
]);
|
|
6
|
+
/** Classify Gemini's `finishReason` (lowercased by the caller). */
|
|
7
|
+
function classifyFinish(finishReason) {
|
|
8
|
+
if (finishReason === undefined)
|
|
9
|
+
return 'other';
|
|
10
|
+
if (GEMINI_DECLINE_REASONS.has(finishReason))
|
|
11
|
+
return 'declined';
|
|
12
|
+
if (finishReason === 'max_tokens')
|
|
13
|
+
return 'truncated';
|
|
14
|
+
if (finishReason === 'stop')
|
|
15
|
+
return 'complete';
|
|
16
|
+
return 'other';
|
|
17
|
+
}
|
|
18
|
+
/** Recursively strip fields that Gemini's API doesn't support (e.g. additionalProperties). */
|
|
19
|
+
function stripUnsupportedSchemaFields(schema) {
|
|
20
|
+
if (Array.isArray(schema)) {
|
|
21
|
+
return schema.map(stripUnsupportedSchemaFields);
|
|
22
|
+
}
|
|
23
|
+
if (schema !== null && typeof schema === 'object') {
|
|
24
|
+
const result = {};
|
|
25
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
26
|
+
if (key === 'additionalProperties')
|
|
27
|
+
continue;
|
|
28
|
+
result[key] = stripUnsupportedSchemaFields(value);
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
return schema;
|
|
33
|
+
}
|
|
34
|
+
export const geminiProvider = {
|
|
35
|
+
name: 'gemini',
|
|
36
|
+
async complete(request) {
|
|
37
|
+
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${request.apiModelId}:generateContent`;
|
|
38
|
+
request.logger?.info('llm.request', {
|
|
39
|
+
provider: 'gemini', model: request.apiModelId, endpoint,
|
|
40
|
+
...(request.callSite ? { callSite: request.callSite } : {}),
|
|
41
|
+
});
|
|
42
|
+
const startTime = Date.now();
|
|
43
|
+
const generationConfig = {
|
|
44
|
+
maxOutputTokens: request.maxTokens,
|
|
45
|
+
};
|
|
46
|
+
// Contract parity with the Anthropic/OpenAI adapters: omit `temperature`
|
|
47
|
+
// for models whose registry entry says they reject it.
|
|
48
|
+
if (request.supportsTemperature) {
|
|
49
|
+
generationConfig.temperature = request.temperature;
|
|
50
|
+
}
|
|
51
|
+
if (request.jsonSchema) {
|
|
52
|
+
generationConfig.responseMimeType = 'application/json';
|
|
53
|
+
generationConfig.responseSchema = stripUnsupportedSchemaFields(request.jsonSchema.schema);
|
|
54
|
+
}
|
|
55
|
+
// Gemini thinking models reason before answering; the reasoning tokens
|
|
56
|
+
// count against maxOutputTokens. Capping the thinking budget reserves
|
|
57
|
+
// headroom for the visible response so a long reasoning pass can't
|
|
58
|
+
// truncate it. Models with a thinking floor (registry
|
|
59
|
+
// `supportsThinkingBudgetZero: false`) reject an explicit 0 budget with a
|
|
60
|
+
// 400 — for those a 0 means "as little thinking as possible", which their
|
|
61
|
+
// default already is, so the config is omitted entirely.
|
|
62
|
+
if (request.thinkingBudget !== undefined
|
|
63
|
+
&& (request.thinkingBudget !== 0 || request.supportsThinkingBudgetZero)) {
|
|
64
|
+
generationConfig.thinkingConfig = { thinkingBudget: request.thinkingBudget };
|
|
65
|
+
}
|
|
66
|
+
const body = {
|
|
67
|
+
contents: [{ parts: [{ text: request.prompt }] }],
|
|
68
|
+
generationConfig,
|
|
69
|
+
};
|
|
70
|
+
if (request.systemPrompt) {
|
|
71
|
+
body.systemInstruction = { parts: [{ text: request.systemPrompt }] };
|
|
72
|
+
}
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const timer = setTimeout(() => controller.abort(), request.timeoutMs);
|
|
75
|
+
let response;
|
|
76
|
+
try {
|
|
77
|
+
response = await fetch(`${endpoint}?key=${request.apiKey}`, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify(body),
|
|
81
|
+
signal: controller.signal,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
const message = err instanceof Error && err.name === 'AbortError'
|
|
86
|
+
? `Gemini API timeout after ${request.timeoutMs}ms`
|
|
87
|
+
: `Gemini API network error: ${err instanceof Error ? err.message : String(err)}`;
|
|
88
|
+
throw new LLMError({
|
|
89
|
+
message,
|
|
90
|
+
provider: 'gemini',
|
|
91
|
+
model: request.apiModelId,
|
|
92
|
+
isRetryable: true,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
}
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const errorText = await response.text();
|
|
100
|
+
throw new LLMError({
|
|
101
|
+
message: `Gemini API error: ${response.status} - ${errorText.slice(0, 500)}`,
|
|
102
|
+
provider: 'gemini',
|
|
103
|
+
model: request.apiModelId,
|
|
104
|
+
statusCode: response.status,
|
|
105
|
+
isRetryable: response.status >= 500 || response.status === 429,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const data = await response.json();
|
|
109
|
+
const latencyMs = Date.now() - startTime;
|
|
110
|
+
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || '';
|
|
111
|
+
const tokensUsed = data.usageMetadata?.totalTokenCount || 0;
|
|
112
|
+
const outputTokens = data.usageMetadata?.candidatesTokenCount ?? undefined;
|
|
113
|
+
const finishReason = data.candidates?.[0]?.finishReason?.toLowerCase() ?? undefined;
|
|
114
|
+
let json;
|
|
115
|
+
if (request.jsonSchema) {
|
|
116
|
+
try {
|
|
117
|
+
json = JSON.parse(text);
|
|
118
|
+
}
|
|
119
|
+
catch { /* leave as text */ }
|
|
120
|
+
}
|
|
121
|
+
request.logger?.info('llm.response', {
|
|
122
|
+
provider: 'gemini', model: request.apiModelId,
|
|
123
|
+
status: response.status, latencyMs, tokensUsed, outputTokens, finishReason,
|
|
124
|
+
...(request.callSite ? { callSite: request.callSite } : {}),
|
|
125
|
+
});
|
|
126
|
+
return { text, json, tokensUsed, outputTokens, finishReason, finishCategory: classifyFinish(finishReason), latencyMs };
|
|
127
|
+
},
|
|
128
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { LLMError } from '../types.js';
|
|
2
|
+
/** True when a JSON-schema `type` value denotes (or includes) an object. */
|
|
3
|
+
function isObjectSchema(type) {
|
|
4
|
+
return type === 'object' || (Array.isArray(type) && type.includes('object'));
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Widen a property schema so OpenAI strict mode permits `null`. OpenAI has no
|
|
8
|
+
* notion of an optional property (every property must be `required`), so an
|
|
9
|
+
* originally-optional field is emulated by making its type nullable — the model
|
|
10
|
+
* may then emit `null` to mean "absent". A nullable enum must also list `null`
|
|
11
|
+
* among its allowed values, or the union with `null` is unsatisfiable.
|
|
12
|
+
*/
|
|
13
|
+
function makeNullable(propSchema) {
|
|
14
|
+
if (propSchema === null || typeof propSchema !== 'object' || Array.isArray(propSchema)) {
|
|
15
|
+
return propSchema;
|
|
16
|
+
}
|
|
17
|
+
const s = propSchema;
|
|
18
|
+
const type = s.type;
|
|
19
|
+
let nextType;
|
|
20
|
+
if (typeof type === 'string') {
|
|
21
|
+
nextType = type === 'null' ? type : [type, 'null'];
|
|
22
|
+
}
|
|
23
|
+
else if (Array.isArray(type)) {
|
|
24
|
+
nextType = type.includes('null') ? type : [...type, 'null'];
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
// No concrete `type` to widen (e.g. anyOf/$ref) — leave as-is.
|
|
28
|
+
return s;
|
|
29
|
+
}
|
|
30
|
+
const result = { ...s, type: nextType };
|
|
31
|
+
if (Array.isArray(s.enum) && !s.enum.includes(null)) {
|
|
32
|
+
result.enum = [...s.enum, null];
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Transform a portable JSON schema into the shape OpenAI's strict Structured
|
|
38
|
+
* Outputs mode requires: every object must set `additionalProperties: false`
|
|
39
|
+
* and list ALL of its properties in `required`. Callers author one portable
|
|
40
|
+
* schema (which also works on Anthropic's tool_use and Gemini's responseSchema,
|
|
41
|
+
* both of which tolerate missing `additionalProperties` and optional fields);
|
|
42
|
+
* this keeps the OpenAI-specific dialect local to this adapter, mirroring the
|
|
43
|
+
* Gemini adapter's own `stripUnsupportedSchemaFields`. Returns a deep copy — the
|
|
44
|
+
* caller's schema object is never mutated (it is reused across failover attempts).
|
|
45
|
+
*/
|
|
46
|
+
function toStrictSchema(node) {
|
|
47
|
+
if (Array.isArray(node))
|
|
48
|
+
return node.map(toStrictSchema);
|
|
49
|
+
if (node === null || typeof node !== 'object')
|
|
50
|
+
return node;
|
|
51
|
+
const obj = node;
|
|
52
|
+
const originalRequired = new Set(Array.isArray(obj.required)
|
|
53
|
+
? obj.required.filter((k) => typeof k === 'string')
|
|
54
|
+
: []);
|
|
55
|
+
// Deep-copy + recurse into every child first so nested objects/arrays are
|
|
56
|
+
// normalized before we widen originally-optional properties above them.
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
59
|
+
out[key] = toStrictSchema(value);
|
|
60
|
+
}
|
|
61
|
+
const properties = out.properties;
|
|
62
|
+
if (isObjectSchema(out.type) && properties !== null && typeof properties === 'object') {
|
|
63
|
+
const propMap = properties;
|
|
64
|
+
const keys = Object.keys(propMap);
|
|
65
|
+
for (const key of keys) {
|
|
66
|
+
if (!originalRequired.has(key)) {
|
|
67
|
+
propMap[key] = makeNullable(propMap[key]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
out.required = keys;
|
|
71
|
+
out.additionalProperties = false;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** Classify OpenAI's `finish_reason` (already lowercase from the API). */
|
|
76
|
+
function classifyFinish(finishReason) {
|
|
77
|
+
switch (finishReason) {
|
|
78
|
+
case 'content_filter': return 'declined';
|
|
79
|
+
case 'length': return 'truncated';
|
|
80
|
+
case 'stop':
|
|
81
|
+
case 'tool_calls':
|
|
82
|
+
case 'function_call': return 'complete';
|
|
83
|
+
default: return 'other';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export const openaiProvider = {
|
|
87
|
+
name: 'openai',
|
|
88
|
+
async complete(request) {
|
|
89
|
+
const endpoint = 'https://api.openai.com/v1/chat/completions';
|
|
90
|
+
request.logger?.info('llm.request', {
|
|
91
|
+
provider: 'openai', model: request.apiModelId, endpoint,
|
|
92
|
+
...(request.callSite ? { callSite: request.callSite } : {}),
|
|
93
|
+
});
|
|
94
|
+
const startTime = Date.now();
|
|
95
|
+
const messages = [];
|
|
96
|
+
if (request.systemPrompt) {
|
|
97
|
+
messages.push({ role: 'system', content: request.systemPrompt });
|
|
98
|
+
}
|
|
99
|
+
messages.push({ role: 'user', content: request.prompt });
|
|
100
|
+
const body = {
|
|
101
|
+
model: request.apiModelId,
|
|
102
|
+
max_completion_tokens: request.maxTokens,
|
|
103
|
+
messages,
|
|
104
|
+
};
|
|
105
|
+
// Reasoning models (the gpt-5.6 family) reject any non-default
|
|
106
|
+
// `temperature` with 400 unsupported_value — omit it per the registry flag.
|
|
107
|
+
if (request.supportsTemperature) {
|
|
108
|
+
body.temperature = request.temperature;
|
|
109
|
+
}
|
|
110
|
+
if (request.jsonSchema) {
|
|
111
|
+
body.response_format = {
|
|
112
|
+
type: 'json_schema',
|
|
113
|
+
json_schema: {
|
|
114
|
+
name: request.jsonSchema.name,
|
|
115
|
+
// Normalize the portable schema into OpenAI's strict dialect
|
|
116
|
+
// (additionalProperties:false everywhere, all properties required,
|
|
117
|
+
// originally-optional ones made nullable). Without this, strict mode
|
|
118
|
+
// rejects the request with a 400 and this provider is dead as a
|
|
119
|
+
// failover target for any schema using optional fields.
|
|
120
|
+
schema: toStrictSchema(request.jsonSchema.schema),
|
|
121
|
+
strict: true,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const controller = new AbortController();
|
|
126
|
+
const timer = setTimeout(() => controller.abort(), request.timeoutMs);
|
|
127
|
+
let response;
|
|
128
|
+
try {
|
|
129
|
+
response = await fetch(endpoint, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: {
|
|
132
|
+
'Content-Type': 'application/json',
|
|
133
|
+
'Authorization': `Bearer ${request.apiKey}`,
|
|
134
|
+
},
|
|
135
|
+
body: JSON.stringify(body),
|
|
136
|
+
signal: controller.signal,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
const message = err instanceof Error && err.name === 'AbortError'
|
|
141
|
+
? `OpenAI API timeout after ${request.timeoutMs}ms`
|
|
142
|
+
: `OpenAI API network error: ${err instanceof Error ? err.message : String(err)}`;
|
|
143
|
+
throw new LLMError({
|
|
144
|
+
message,
|
|
145
|
+
provider: 'openai',
|
|
146
|
+
model: request.apiModelId,
|
|
147
|
+
isRetryable: true,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
}
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
const errorText = await response.text();
|
|
155
|
+
throw new LLMError({
|
|
156
|
+
message: `OpenAI API error: ${response.status} - ${errorText.slice(0, 500)}`,
|
|
157
|
+
provider: 'openai',
|
|
158
|
+
model: request.apiModelId,
|
|
159
|
+
statusCode: response.status,
|
|
160
|
+
isRetryable: response.status >= 500 || response.status === 429,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const data = await response.json();
|
|
164
|
+
const latencyMs = Date.now() - startTime;
|
|
165
|
+
const text = data.choices?.[0]?.message?.content || '';
|
|
166
|
+
const outputTokens = data.usage?.completion_tokens ?? undefined;
|
|
167
|
+
const tokensUsed = (data.usage?.prompt_tokens || 0) + (data.usage?.completion_tokens || 0);
|
|
168
|
+
const finishReason = data.choices?.[0]?.finish_reason ?? undefined;
|
|
169
|
+
let json;
|
|
170
|
+
if (request.jsonSchema) {
|
|
171
|
+
try {
|
|
172
|
+
json = JSON.parse(text);
|
|
173
|
+
}
|
|
174
|
+
catch { /* leave as text */ }
|
|
175
|
+
}
|
|
176
|
+
request.logger?.info('llm.response', {
|
|
177
|
+
provider: 'openai', model: request.apiModelId,
|
|
178
|
+
status: response.status, latencyMs, tokensUsed, outputTokens, finishReason,
|
|
179
|
+
...(request.callSite ? { callSite: request.callSite } : {}),
|
|
180
|
+
});
|
|
181
|
+
return { text, json, tokensUsed, outputTokens, finishReason, finishCategory: classifyFinish(finishReason), latencyMs };
|
|
182
|
+
},
|
|
183
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { Logger } from '../log-core.js';
|
|
2
|
+
/** Supported LLM provider names. */
|
|
3
|
+
export type ProviderName = 'anthropic' | 'gemini' | 'openai';
|
|
4
|
+
/** API keys for each provider. Only keys for available providers need to be set. */
|
|
5
|
+
export interface ApiKeys {
|
|
6
|
+
anthropic?: string;
|
|
7
|
+
gemini?: string;
|
|
8
|
+
openai?: string;
|
|
9
|
+
}
|
|
10
|
+
/** JSON schema definition for structured output. */
|
|
11
|
+
export interface JsonSchema {
|
|
12
|
+
/** Schema name (used by OpenAI and Anthropic). */
|
|
13
|
+
name: string;
|
|
14
|
+
/** JSON Schema object. */
|
|
15
|
+
schema: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
/** Request to llmComplete(). */
|
|
18
|
+
export interface LLMRequest {
|
|
19
|
+
/** Model name from the registry (e.g. 'claude-sonnet-4-6', 'gemini-2.5-flash'). */
|
|
20
|
+
model: string;
|
|
21
|
+
/** The user prompt. */
|
|
22
|
+
prompt: string;
|
|
23
|
+
/** Optional system prompt. */
|
|
24
|
+
systemPrompt?: string;
|
|
25
|
+
/** Max output tokens. Default: 2048. */
|
|
26
|
+
maxTokens?: number;
|
|
27
|
+
/** Temperature. Default: 0.3. */
|
|
28
|
+
temperature?: number;
|
|
29
|
+
/** If provided, requests structured JSON output matching this schema. */
|
|
30
|
+
jsonSchema?: JsonSchema;
|
|
31
|
+
/** API keys for available providers. */
|
|
32
|
+
apiKeys: ApiKeys;
|
|
33
|
+
/** Logger instance for structured logging. */
|
|
34
|
+
logger?: Logger;
|
|
35
|
+
/** Timeout per provider attempt in ms. Default: 60000. */
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Cap on reasoning/thinking tokens for models that support hidden reasoning
|
|
39
|
+
* (currently Gemini 2.5 family). Reasoning tokens are charged against the
|
|
40
|
+
* same budget as output tokens, so when thinking is unbounded it can starve
|
|
41
|
+
* the visible response and cause mid-string truncation. Setting this
|
|
42
|
+
* reserves headroom for the response. Ignored by providers that don't
|
|
43
|
+
* expose a thinking budget.
|
|
44
|
+
*/
|
|
45
|
+
thinkingBudget?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Output-effort level for models that support Anthropic's `output_config.
|
|
48
|
+
* effort` (registry `supportsEffort`). Lower effort trades thoroughness for
|
|
49
|
+
* latency/cost. Currently honored on Anthropic models only; when a call
|
|
50
|
+
* fails over to another provider the fallback runs at its own defaults.
|
|
51
|
+
*/
|
|
52
|
+
effort?: 'low' | 'medium' | 'high';
|
|
53
|
+
/**
|
|
54
|
+
* Optional short identifier for the calling feature (e.g. 'video_classify',
|
|
55
|
+
* 'message_prioritize', 'log_summary'). Included in llm.request and
|
|
56
|
+
* llm.response log meta so cost/usage reports can attribute spend to the
|
|
57
|
+
* specific feature driving the call. No behavioral effect.
|
|
58
|
+
*/
|
|
59
|
+
callSite?: string;
|
|
60
|
+
/**
|
|
61
|
+
* If true, request Anthropic prompt caching for the system prompt. The
|
|
62
|
+
* library wraps the system string in a content-block array with
|
|
63
|
+
* `cache_control: { type: 'ephemeral' }`. Non-Anthropic providers ignore
|
|
64
|
+
* this flag (they have no equivalent caching mechanism here). Has no
|
|
65
|
+
* effect when `systemPrompt` is empty.
|
|
66
|
+
*/
|
|
67
|
+
cacheSystemPrompt?: boolean;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Semantic classification of a provider's finish reason, normalized across
|
|
71
|
+
* providers so the failover loop needs no provider-specific knowledge:
|
|
72
|
+
* - `complete` — the model finished normally (incl. tool/function calls).
|
|
73
|
+
* - `truncated` — output was cut off by the token budget. The caller's concern;
|
|
74
|
+
* does NOT trigger failover, since a re-run would likely truncate too.
|
|
75
|
+
* - `declined` — the model actively refused / was content-filtered. Triggers
|
|
76
|
+
* failover to the next provider, which may have different policies.
|
|
77
|
+
* - `other` — anything unrecognized (including a missing finish reason).
|
|
78
|
+
*
|
|
79
|
+
* Each provider adapter maps its native finish reason to one of these, keeping
|
|
80
|
+
* provider-specific vocabulary local to the adapter that owns it.
|
|
81
|
+
*/
|
|
82
|
+
export type FinishCategory = 'complete' | 'truncated' | 'declined' | 'other';
|
|
83
|
+
/** Successful response from llmComplete(). */
|
|
84
|
+
export interface LLMResponse {
|
|
85
|
+
/** The text output (or JSON stringified if jsonSchema was used). */
|
|
86
|
+
text: string;
|
|
87
|
+
/** Parsed JSON output when jsonSchema was requested. */
|
|
88
|
+
json?: unknown;
|
|
89
|
+
/** The provider that produced this response. */
|
|
90
|
+
provider: ProviderName;
|
|
91
|
+
/** The API model ID that produced this response. */
|
|
92
|
+
model: string;
|
|
93
|
+
/** Total tokens used (input + output). */
|
|
94
|
+
tokensUsed: number;
|
|
95
|
+
/** Output tokens only, when the provider reports them separately. */
|
|
96
|
+
outputTokens?: number;
|
|
97
|
+
/**
|
|
98
|
+
* Prompt-cache tokens read from the cache (Anthropic only, when caching is
|
|
99
|
+
* used). These are billed at a reduced rate vs. regular input tokens.
|
|
100
|
+
*/
|
|
101
|
+
cacheReadTokens?: number;
|
|
102
|
+
/**
|
|
103
|
+
* Prompt-cache tokens written to the cache (Anthropic only, when caching is
|
|
104
|
+
* used). These are billed at a premium over regular input tokens.
|
|
105
|
+
*/
|
|
106
|
+
cacheCreationTokens?: number;
|
|
107
|
+
/**
|
|
108
|
+
* Normalized finish reason from the provider, when available.
|
|
109
|
+
* Lowercased common values: 'stop', 'max_tokens', 'tool_use', 'content_filter',
|
|
110
|
+
* 'safety', 'length', 'other'. Provider-specific strings may also appear.
|
|
111
|
+
*/
|
|
112
|
+
finishReason?: string;
|
|
113
|
+
/** Semantic classification of {@link finishReason}; see {@link FinishCategory}. */
|
|
114
|
+
finishCategory?: FinishCategory;
|
|
115
|
+
/** Wall-clock latency of the successful call in ms. */
|
|
116
|
+
latencyMs: number;
|
|
117
|
+
/** True if the primary model failed and a fallback was used. */
|
|
118
|
+
failedOver: boolean;
|
|
119
|
+
/** Details of each failed attempt before the successful one. */
|
|
120
|
+
failedAttempts: FailedAttempt[];
|
|
121
|
+
}
|
|
122
|
+
/** Details of a failed provider attempt. */
|
|
123
|
+
export interface FailedAttempt {
|
|
124
|
+
provider: ProviderName;
|
|
125
|
+
model: string;
|
|
126
|
+
error: string;
|
|
127
|
+
/** True when this attempt failed because the model declined (vs. an infra error). */
|
|
128
|
+
declined?: boolean;
|
|
129
|
+
}
|
|
130
|
+
/** Error thrown when an LLM provider call fails. */
|
|
131
|
+
export declare class LLMError extends Error {
|
|
132
|
+
provider: ProviderName;
|
|
133
|
+
model: string;
|
|
134
|
+
statusCode?: number;
|
|
135
|
+
isRetryable: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* True when this error is the model actively declining to respond (safety
|
|
138
|
+
* refusal / content filter) rather than an infrastructure failure. On the
|
|
139
|
+
* terminal "all providers failed" error, true only when *every* attempt was
|
|
140
|
+
* a decline. Callers can use this to classify severity — e.g. log WARN, not
|
|
141
|
+
* ERROR, since an across-the-board decline is an expected external outcome,
|
|
142
|
+
* not a defect.
|
|
143
|
+
*/
|
|
144
|
+
declined: boolean;
|
|
145
|
+
constructor(opts: {
|
|
146
|
+
message: string;
|
|
147
|
+
provider: ProviderName;
|
|
148
|
+
model: string;
|
|
149
|
+
statusCode?: number;
|
|
150
|
+
isRetryable: boolean;
|
|
151
|
+
declined?: boolean;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
export interface ProviderAdapter {
|
|
155
|
+
name: ProviderName;
|
|
156
|
+
complete(request: ProviderCallRequest): Promise<ProviderCallResponse>;
|
|
157
|
+
}
|
|
158
|
+
export interface ProviderCallRequest {
|
|
159
|
+
apiModelId: string;
|
|
160
|
+
prompt: string;
|
|
161
|
+
systemPrompt?: string;
|
|
162
|
+
maxTokens: number;
|
|
163
|
+
temperature: number;
|
|
164
|
+
/**
|
|
165
|
+
* Whether the target model accepts the `temperature` parameter, per the
|
|
166
|
+
* MODEL_REGISTRY entry's `supportsTemperature` flag. Providers that always
|
|
167
|
+
* accept temperature can ignore this; providers with models that reject it
|
|
168
|
+
* must omit `temperature` from the request body when this is `false`.
|
|
169
|
+
*/
|
|
170
|
+
supportsTemperature: boolean;
|
|
171
|
+
/**
|
|
172
|
+
* Whether the target model accepts `thinkingBudget: 0`, per the registry
|
|
173
|
+
* entry's `supportsThinkingBudgetZero` flag. When `false`, the Gemini
|
|
174
|
+
* adapter omits thinkingConfig for a 0 budget instead of sending it (the
|
|
175
|
+
* model would 400); non-zero budgets pass through unchanged.
|
|
176
|
+
*/
|
|
177
|
+
supportsThinkingBudgetZero: boolean;
|
|
178
|
+
jsonSchema?: JsonSchema;
|
|
179
|
+
apiKey: string;
|
|
180
|
+
logger?: Logger;
|
|
181
|
+
timeoutMs: number;
|
|
182
|
+
thinkingBudget?: number;
|
|
183
|
+
/** Effort level, pre-gated by the registry's `supportsEffort` (absent when unsupported). */
|
|
184
|
+
effort?: 'low' | 'medium' | 'high';
|
|
185
|
+
callSite?: string;
|
|
186
|
+
cacheSystemPrompt?: boolean;
|
|
187
|
+
}
|
|
188
|
+
export interface ProviderCallResponse {
|
|
189
|
+
text: string;
|
|
190
|
+
json?: unknown;
|
|
191
|
+
tokensUsed: number;
|
|
192
|
+
/** Output tokens only, when the provider reports them separately. */
|
|
193
|
+
outputTokens?: number;
|
|
194
|
+
/** Prompt-cache read tokens (Anthropic, when caching is used). */
|
|
195
|
+
cacheReadTokens?: number;
|
|
196
|
+
/** Prompt-cache creation tokens (Anthropic, when caching is used). */
|
|
197
|
+
cacheCreationTokens?: number;
|
|
198
|
+
/** Normalized finish reason (lowercased common values where known). */
|
|
199
|
+
finishReason?: string;
|
|
200
|
+
/** Semantic classification of {@link finishReason}; see {@link FinishCategory}. */
|
|
201
|
+
finishCategory?: FinishCategory;
|
|
202
|
+
latencyMs: number;
|
|
203
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Error thrown when an LLM provider call fails. */
|
|
2
|
+
export class LLMError extends Error {
|
|
3
|
+
provider;
|
|
4
|
+
model;
|
|
5
|
+
statusCode;
|
|
6
|
+
isRetryable;
|
|
7
|
+
/**
|
|
8
|
+
* True when this error is the model actively declining to respond (safety
|
|
9
|
+
* refusal / content filter) rather than an infrastructure failure. On the
|
|
10
|
+
* terminal "all providers failed" error, true only when *every* attempt was
|
|
11
|
+
* a decline. Callers can use this to classify severity — e.g. log WARN, not
|
|
12
|
+
* ERROR, since an across-the-board decline is an expected external outcome,
|
|
13
|
+
* not a defect.
|
|
14
|
+
*/
|
|
15
|
+
declined;
|
|
16
|
+
constructor(opts) {
|
|
17
|
+
super(opts.message);
|
|
18
|
+
this.name = 'LLMError';
|
|
19
|
+
this.provider = opts.provider;
|
|
20
|
+
this.model = opts.model;
|
|
21
|
+
this.statusCode = opts.statusCode;
|
|
22
|
+
this.isRetryable = opts.isRetryable;
|
|
23
|
+
this.declined = opts.declined ?? false;
|
|
24
|
+
}
|
|
25
|
+
}
|