- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift) - LogLift evidence pipeline (migration 078): upload webhook, B2 storage client, receiver/matcher, EventLogCollector PowerShell script - IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket xrefs, applications/configurations browse pages + apply/revert/audit endpoints - Link-aware analyzer bundles (migration 073) + provider toggle (migration 074): link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion panels, analyze-bundle endpoint - Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts admin page, reconciler service, resolve endpoints - Dashboard overhaul: integration-health service + alerts, overview/health endpoints - Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
6.8 KiB
TypeScript
243 lines
6.8 KiB
TypeScript
/**
|
|
* Generic LLM caller for the analyzer pipeline.
|
|
*
|
|
* One round-trip is:
|
|
* 1. Send (system, user) to the chosen model (Anthropic or OpenRouter).
|
|
* 2. Extract the assistant's text content.
|
|
* 3. JSON.parse + Zod-validate against the caller's schema.
|
|
* 4. On failure: retry ONCE with the prior raw response + parse error in a
|
|
* follow-up user turn, then validate again.
|
|
* 5. After two failures: throw.
|
|
*
|
|
* Provider dispatch:
|
|
* - claude-* → Anthropic SDK (with prompt-cache hint on the system prefix)
|
|
* - <vendor>/<model> → OpenRouter chat-completions (OpenAI-compatible)
|
|
*
|
|
* The retry logic, schema validation, and result shape are identical across
|
|
* providers so callers stay provider-agnostic.
|
|
*/
|
|
|
|
import type Anthropic from '@anthropic-ai/sdk';
|
|
import type { ZodType } from 'zod';
|
|
import { getAnthropicClient } from './client';
|
|
import { callOpenRouterChat } from './openrouter-call';
|
|
import { estimateCostUsd, type TokenUsage } from './pricing';
|
|
import {
|
|
type ModelId,
|
|
OPUS,
|
|
providerForModel,
|
|
} from './models';
|
|
|
|
export interface LLMCallOptions<T> {
|
|
model: ModelId;
|
|
system: string;
|
|
user: string;
|
|
schema: ZodType<T>;
|
|
maxTokens: number;
|
|
/** Override the Anthropic singleton (test injection). */
|
|
client?: Anthropic;
|
|
}
|
|
|
|
export interface LLMCallResult<T> {
|
|
data: T;
|
|
usage: TokenUsage;
|
|
estimated_cost_usd: number;
|
|
/** Number of attempts made (1 = first try succeeded; 2 = retry succeeded). */
|
|
attempts: 1 | 2;
|
|
/** Raw response text for debugging / model_traces. */
|
|
raw_response: string;
|
|
}
|
|
|
|
interface RoundTripResult {
|
|
text: string;
|
|
usage: TokenUsage;
|
|
}
|
|
|
|
type RoundTripFn = (
|
|
history: Array<{ role: 'user' | 'assistant'; content: string }>
|
|
) => Promise<RoundTripResult>;
|
|
|
|
function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
|
|
return {
|
|
input_tokens: a.input_tokens + b.input_tokens,
|
|
output_tokens: a.output_tokens + b.output_tokens,
|
|
cache_creation_input_tokens:
|
|
(a.cache_creation_input_tokens ?? 0) + (b.cache_creation_input_tokens ?? 0),
|
|
cache_read_input_tokens:
|
|
(a.cache_read_input_tokens ?? 0) + (b.cache_read_input_tokens ?? 0),
|
|
};
|
|
}
|
|
|
|
interface AnthropicMessagesCreateBody {
|
|
model: string;
|
|
max_tokens: number;
|
|
system: Array<{
|
|
type: 'text';
|
|
text: string;
|
|
cache_control?: { type: 'ephemeral' };
|
|
}>;
|
|
messages: Array<{ role: 'user' | 'assistant'; content: string }>;
|
|
}
|
|
|
|
function buildAnthropicBody(opts: {
|
|
model: ModelId;
|
|
system: string;
|
|
history: Array<{ role: 'user' | 'assistant'; content: string }>;
|
|
maxTokens: number;
|
|
}): AnthropicMessagesCreateBody {
|
|
return {
|
|
model: opts.model,
|
|
max_tokens: opts.maxTokens,
|
|
system: [
|
|
{
|
|
type: 'text',
|
|
text: opts.system,
|
|
cache_control: { type: 'ephemeral' },
|
|
},
|
|
],
|
|
messages: opts.history,
|
|
};
|
|
}
|
|
|
|
function extractAnthropicText(response: Anthropic.Message): string {
|
|
const parts: string[] = [];
|
|
for (const block of response.content) {
|
|
if (block.type === 'text') parts.push(block.text);
|
|
}
|
|
return parts.join('').trim();
|
|
}
|
|
|
|
/**
|
|
* Some models occasionally wrap JSON in code fences despite "respond ONLY with
|
|
* JSON" instructions. Strip a single ```json ... ``` fence if present so the
|
|
* happy path doesn't bounce into the retry just for that.
|
|
*/
|
|
function unwrapFences(text: string): string {
|
|
const trimmed = text.trim();
|
|
if (trimmed.startsWith('```')) {
|
|
const stripped = trimmed
|
|
.replace(/^```(?:json)?\s*/i, '')
|
|
.replace(/```\s*$/, '')
|
|
.trim();
|
|
return stripped;
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function tryParseValidate<T>(
|
|
text: string,
|
|
schema: ZodType<T>
|
|
): { ok: true; value: T } | { ok: false; error: string } {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(unwrapFences(text));
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
error: `JSON.parse failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
};
|
|
}
|
|
const result = schema.safeParse(parsed);
|
|
if (!result.success) {
|
|
return {
|
|
ok: false,
|
|
error: `Zod validation failed: ${JSON.stringify(result.error.issues, null, 2)}`,
|
|
};
|
|
}
|
|
return { ok: true, value: result.data };
|
|
}
|
|
|
|
function makeAnthropicRoundTrip(
|
|
opts: LLMCallOptions<unknown>,
|
|
client: Anthropic
|
|
): RoundTripFn {
|
|
return async (history) => {
|
|
const body = buildAnthropicBody({
|
|
model: opts.model,
|
|
system: opts.system,
|
|
history,
|
|
maxTokens: opts.maxTokens,
|
|
});
|
|
void (body satisfies Anthropic.MessageCreateParamsNonStreaming);
|
|
const response = await client.messages.create(body);
|
|
return {
|
|
text: extractAnthropicText(response),
|
|
usage: response.usage as TokenUsage,
|
|
};
|
|
};
|
|
}
|
|
|
|
function makeOpenRouterRoundTrip(opts: LLMCallOptions<unknown>): RoundTripFn {
|
|
return async (history) => {
|
|
const result = await callOpenRouterChat({
|
|
model: opts.model,
|
|
system: opts.system,
|
|
history,
|
|
maxTokens: opts.maxTokens,
|
|
});
|
|
return { text: result.text, usage: result.usage };
|
|
};
|
|
}
|
|
|
|
export async function callLLMStage<T>(
|
|
opts: LLMCallOptions<T>
|
|
): Promise<LLMCallResult<T>> {
|
|
const provider = providerForModel(opts.model);
|
|
const roundTrip: RoundTripFn =
|
|
provider === 'anthropic'
|
|
? makeAnthropicRoundTrip(opts, opts.client ?? getAnthropicClient())
|
|
: makeOpenRouterRoundTrip(opts);
|
|
|
|
const history: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
|
{ role: 'user', content: opts.user },
|
|
];
|
|
|
|
const first = await roundTrip(history);
|
|
const firstParse = tryParseValidate(first.text, opts.schema);
|
|
|
|
if (firstParse.ok) {
|
|
return {
|
|
data: firstParse.value,
|
|
usage: first.usage,
|
|
estimated_cost_usd: estimateCostUsd(opts.model, first.usage),
|
|
attempts: 1,
|
|
raw_response: first.text,
|
|
};
|
|
}
|
|
|
|
// Retry once. Append the model's previous (invalid) response and a follow-up
|
|
// user turn explaining the parse error.
|
|
history.push({ role: 'assistant', content: first.text });
|
|
history.push({
|
|
role: 'user',
|
|
content: [
|
|
'Your previous response could not be parsed. Error:',
|
|
firstParse.error,
|
|
'',
|
|
'Re-emit the SAME response, corrected to be valid JSON that conforms to the requested schema.',
|
|
'Respond ONLY with JSON. No prose, no code fences.',
|
|
].join('\n'),
|
|
});
|
|
|
|
const second = await roundTrip(history);
|
|
const secondParse = tryParseValidate(second.text, opts.schema);
|
|
|
|
const totalUsage = addUsage(first.usage, second.usage);
|
|
|
|
if (!secondParse.ok) {
|
|
throw new Error(
|
|
`LLM stage on ${opts.model} failed twice. First: ${firstParse.error}. Second: ${secondParse.error}. Last raw response: ${second.text.slice(0, 500)}`
|
|
);
|
|
}
|
|
|
|
return {
|
|
data: secondParse.value,
|
|
usage: totalUsage,
|
|
estimated_cost_usd: estimateCostUsd(opts.model, totalUsage),
|
|
attempts: 2,
|
|
raw_response: second.text,
|
|
};
|
|
}
|
|
|
|
// Re-export OPUS so callers don't need a second import for the common case.
|
|
export { OPUS };
|