diff --git a/package.json b/package.json index 6e09c49..78fee44 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "db:studio": "pnpm --filter db db:studio" }, "dependencies": { + "@anthropic-ai/sdk": "^0.92.0", "@payloadcms/admin-bar": "3.64.0", "@payloadcms/db-postgres": "3.64.0", "@payloadcms/live-preview-react": "3.64.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce5a3f4..a5f4e89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.92.0 + version: 0.92.0(zod@4.1.13) '@payloadcms/admin-bar': specifier: 3.64.0 version: 3.64.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -251,6 +254,15 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/sdk@0.92.0': + resolution: {integrity: sha512-l653JFC83wCglH8H83t1xpgDurCyPyslYW1maPRdCsfuNuGbLvQjQ81sWd3Go3LWRm0jNspzAhuqAYV8r9joSw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@apidevtools/json-schema-ref-parser@11.9.3': resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} engines: {node: '>= 16'} @@ -4474,6 +4486,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-to-typescript@15.0.3: resolution: {integrity: sha512-iOKdzTUWEVM4nlxpFudFsWyUiu/Jakkga4OZPEt7CGoSEsAsUgdOZqR6pcgx2STBek9Gm4hcarJpXSzIvZ/hKA==} engines: {node: '>=16.0.0'} @@ -6090,6 +6106,9 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -6598,6 +6617,12 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 + '@anthropic-ai/sdk@0.92.0(zod@4.1.13)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.1.13 + '@apidevtools/json-schema-ref-parser@11.9.3': dependencies: '@jsdevtools/ono': 7.1.3 @@ -11092,6 +11117,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.4 + ts-algebra: 2.0.0 + json-schema-to-typescript@15.0.3: dependencies: '@apidevtools/json-schema-ref-parser': 11.9.3 @@ -13327,6 +13357,8 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 + ts-algebra@2.0.0: {} + ts-api-utils@2.1.0(typescript@5.7.3): dependencies: typescript: 5.7.3 diff --git a/src/lib/replyTriage.ts b/src/lib/replyTriage.ts index 428b876..1316024 100644 --- a/src/lib/replyTriage.ts +++ b/src/lib/replyTriage.ts @@ -1,3 +1,4 @@ +import Anthropic from "@anthropic-ai/sdk"; import type { ReplyIntent } from "@coldflow/db"; export interface TriageInput { @@ -17,7 +18,6 @@ export interface TriageResult { } const CLAUDE_MODEL = process.env.ANTHROPIC_MODEL ?? "claude-haiku-4-5-20251001"; -const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"; const VALID_INTENTS: ReadonlySet = new Set([ "interested", @@ -229,31 +229,20 @@ export const triageReplyLLM = async ( ].join("\n"); try { - const res = await fetch(ANTHROPIC_API_URL, { - method: "POST", - signal: opts?.signal, - headers: { - "Content-Type": "application/json", - "x-api-key": apiKey, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify({ + const client = new Anthropic({ apiKey }); + const message = await client.messages.create( + { model: CLAUDE_MODEL, max_tokens: 600, system: TRIAGE_PROMPT, messages: [{ role: "user", content: userPrompt }], - }), - }); - - if (!res.ok) { - console.error("Anthropic triage non-200:", res.status, await res.text()); - return null; - } + }, + { signal: opts?.signal } + ); - const json = (await res.json()) as { - content?: Array<{ type: string; text?: string }>; - }; - const text = json.content?.find((c) => c.type === "text")?.text?.trim(); + const text = message.content + .find((block): block is Anthropic.TextBlock => block.type === "text") + ?.text.trim(); if (!text) return null; const parsed = parseLLMJson(text); diff --git a/tests/int/replyTriage.int.spec.ts b/tests/int/replyTriage.int.spec.ts index ae7fb4b..3e2c147 100644 --- a/tests/int/replyTriage.int.spec.ts +++ b/tests/int/replyTriage.int.spec.ts @@ -6,6 +6,19 @@ import { triageReplyLLM, } from '@/lib/replyTriage' +const { messagesCreate } = vi.hoisted(() => ({ messagesCreate: vi.fn() })) + +vi.mock('@anthropic-ai/sdk', () => { + const Anthropic = vi.fn(() => ({ + messages: { create: messagesCreate }, + })) + return { default: Anthropic } +}) + +const textResponse = (text: string) => ({ + content: [{ type: 'text', text }], +}) + describe('triageReplyHeuristic', () => { it('classifies the labeled cases.json set with >= 80% accuracy', () => { let correct = 0 @@ -48,32 +61,23 @@ describe('triageReplyHeuristic', () => { }) describe('triageReplyLLM', () => { - const originalFetch = globalThis.fetch beforeEach(() => { delete process.env.ANTHROPIC_API_KEY - }) - afterEach(() => { - globalThis.fetch = originalFetch + messagesCreate.mockReset() }) it('returns null when no API key is configured', async () => { const result = await triageReplyLLM({ replyBody: 'price?' }) expect(result).toBeNull() + expect(messagesCreate).not.toHaveBeenCalled() }) it('parses a valid LLM response', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [ - { - type: 'text', - text: '{"intent":"interested","confidence":0.92,"suggested_followup":"Hi Pat, happy to share details."}', - }, - ], - }), - }) - globalThis.fetch = fetchMock as unknown as typeof fetch + messagesCreate.mockResolvedValue( + textResponse( + '{"intent":"interested","confidence":0.92,"suggested_followup":"Hi Pat, happy to share details."}' + ) + ) const result = await triageReplyLLM( { replyBody: 'price?' }, @@ -88,18 +92,11 @@ describe('triageReplyLLM', () => { }) it('tolerates ```json fences in the LLM response', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [ - { - type: 'text', - text: '```json\n{"intent":"objection","confidence":0.7,"suggested_followup":"x"}\n```', - }, - ], - }), - }) - globalThis.fetch = fetchMock as unknown as typeof fetch + messagesCreate.mockResolvedValue( + textResponse( + '```json\n{"intent":"objection","confidence":0.7,"suggested_followup":"x"}\n```' + ) + ) const result = await triageReplyLLM( { replyBody: 'no thanks' }, { apiKey: 'sk-test' } @@ -108,11 +105,7 @@ describe('triageReplyLLM', () => { }) it('returns null when the LLM returns garbage that cannot be parsed', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ content: [{ type: 'text', text: 'sorry, I cannot help' }] }), - }) - globalThis.fetch = fetchMock as unknown as typeof fetch + messagesCreate.mockResolvedValue(textResponse('sorry, I cannot help')) const result = await triageReplyLLM( { replyBody: '...' }, { apiKey: 'sk-test' } @@ -120,13 +113,8 @@ describe('triageReplyLLM', () => { expect(result).toBeNull() }) - it('returns null when the API returns non-200', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - text: async () => 'server error', - }) - globalThis.fetch = fetchMock as unknown as typeof fetch + it('returns null when the SDK throws', async () => { + messagesCreate.mockRejectedValue(new Error('500 server error')) const result = await triageReplyLLM( { replyBody: '...' }, { apiKey: 'sk-test' } @@ -135,18 +123,9 @@ describe('triageReplyLLM', () => { }) it('rejects unknown intent values', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [ - { - type: 'text', - text: '{"intent":"happy","confidence":0.9,"suggested_followup":"x"}', - }, - ], - }), - }) - globalThis.fetch = fetchMock as unknown as typeof fetch + messagesCreate.mockResolvedValue( + textResponse('{"intent":"happy","confidence":0.9,"suggested_followup":"x"}') + ) const result = await triageReplyLLM( { replyBody: '...' }, { apiKey: 'sk-test' } @@ -155,18 +134,9 @@ describe('triageReplyLLM', () => { }) it('clamps confidence into [0, 1]', async () => { - const fetchMock = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [ - { - type: 'text', - text: '{"intent":"interested","confidence":1.5,"suggested_followup":"x"}', - }, - ], - }), - }) - globalThis.fetch = fetchMock as unknown as typeof fetch + messagesCreate.mockResolvedValue( + textResponse('{"intent":"interested","confidence":1.5,"suggested_followup":"x"}') + ) const result = await triageReplyLLM( { replyBody: 'p' }, { apiKey: 'sk-test' } @@ -176,9 +146,10 @@ describe('triageReplyLLM', () => { }) describe('triageReply (end-to-end)', () => { - const originalFetch = globalThis.fetch + beforeEach(() => { + messagesCreate.mockReset() + }) afterEach(() => { - globalThis.fetch = originalFetch delete process.env.ANTHROPIC_API_KEY }) @@ -191,9 +162,7 @@ describe('triageReply (end-to-end)', () => { it('falls back to the heuristic when the LLM call fails', async () => { process.env.ANTHROPIC_API_KEY = 'sk-test' - globalThis.fetch = vi - .fn() - .mockRejectedValue(new Error('network down')) as unknown as typeof fetch + messagesCreate.mockRejectedValue(new Error('network down')) const result = await triageReply({ replyBody: 'send pricing please' }) expect(result.source).toBe('heuristic') expect(result.intent).toBe('interested') @@ -201,17 +170,11 @@ describe('triageReply (end-to-end)', () => { it('uses LLM result when available', async () => { process.env.ANTHROPIC_API_KEY = 'sk-test' - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - content: [ - { - type: 'text', - text: '{"intent":"not_now","confidence":0.66,"suggested_followup":"talk later"}', - }, - ], - }), - }) as unknown as typeof fetch + messagesCreate.mockResolvedValue( + textResponse( + '{"intent":"not_now","confidence":0.66,"suggested_followup":"talk later"}' + ) + ) const result = await triageReply({ replyBody: 'busy this quarter' }) expect(result.source).toBe('llm') expect(result.intent).toBe('not_now')