From ebaa98d9f476598f8cfafe0ed2a3a78bb67e4078 Mon Sep 17 00:00:00 2001 From: weeihan Date: Sat, 11 Jul 2026 16:10:26 +0800 Subject: [PATCH] feat: Claude client + Voyage AI embed helper Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01FFDuBhMKvoWjrWT3ZnGmmr --- .env.local.example | 2 ++ lib/claude/client.ts | 5 ++++ lib/claude/embed.ts | 14 +++++++++++ package-lock.json | 44 ++++++++++++++++++++++++++++++++-- package.json | 1 + tests/lib/claude/embed.test.ts | 37 ++++++++++++++++++++++++++++ 6 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 lib/claude/client.ts create mode 100644 lib/claude/embed.ts create mode 100644 tests/lib/claude/embed.test.ts diff --git a/.env.local.example b/.env.local.example index 53a7394..41e0e92 100644 --- a/.env.local.example +++ b/.env.local.example @@ -3,3 +3,5 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here NEXT_PUBLIC_APP_URL=http://localhost:3000 CRON_SECRET= +ANTHROPIC_API_KEY=sk-ant-... +VOYAGE_API_KEY=pa-... diff --git a/lib/claude/client.ts b/lib/claude/client.ts new file mode 100644 index 0000000..009e3a2 --- /dev/null +++ b/lib/claude/client.ts @@ -0,0 +1,5 @@ +import Anthropic from '@anthropic-ai/sdk' + +export const anthropic = new Anthropic({ + apiKey: process.env.ANTHROPIC_API_KEY ?? '', +}) diff --git a/lib/claude/embed.ts b/lib/claude/embed.ts new file mode 100644 index 0000000..a440b50 --- /dev/null +++ b/lib/claude/embed.ts @@ -0,0 +1,14 @@ +export async function embedText(text: string): Promise { + if (!process.env.VOYAGE_API_KEY) throw new Error('VOYAGE_API_KEY is not set') + const res = await fetch('https://api.voyageai.com/v1/embeddings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${process.env.VOYAGE_API_KEY}`, + }, + body: JSON.stringify({ input: [text], model: 'voyage-3-lite' }), + }) + if (!res.ok) throw new Error(`Voyage embed failed: ${res.status}`) + const json = await res.json() as { data: Array<{ embedding: number[] }> } + return json.data[0].embedding +} diff --git a/package-lock.json b/package-lock.json index 9eeccc8..2e802fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "ims-app", "version": "0.1.0", "dependencies": { + "@anthropic-ai/sdk": "^0.111.0", "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.110.2", "next": "^15.5.20", @@ -55,6 +56,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.111.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.111.0.tgz", + "integrity": "sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -302,7 +324,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -6451,6 +6472,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -8592,6 +8626,12 @@ "node": ">=20" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -9437,7 +9477,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 3feac3c..7a47b11 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "generate-qr": "tsx scripts/generate-qr.ts" }, "dependencies": { + "@anthropic-ai/sdk": "^0.111.0", "@supabase/ssr": "^0.12.0", "@supabase/supabase-js": "^2.110.2", "next": "^15.5.20", diff --git a/tests/lib/claude/embed.test.ts b/tests/lib/claude/embed.test.ts new file mode 100644 index 0000000..c4c4b00 --- /dev/null +++ b/tests/lib/claude/embed.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +describe('embedText', () => { + beforeEach(() => { + process.env.VOYAGE_API_KEY = 'test-key' + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns 1024-element embedding array', async () => { + const mockEmbedding = Array.from({ length: 1024 }, (_, i) => i / 1024) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: [{ embedding: mockEmbedding }] }), + })) + const { embedText } = await import('@/lib/claude/embed') + const result = await embedText('forklift hit racking in zone B') + expect(result).toHaveLength(1024) + expect(result[0]).toBeCloseTo(0) + expect(result[1023]).toBeCloseTo(1023 / 1024) + }) + + it('throws on non-ok response', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 401 })) + const { embedText } = await import('@/lib/claude/embed') + await expect(embedText('test')).rejects.toThrow('Voyage embed failed: 401') + }) + + it('throws when VOYAGE_API_KEY is missing', async () => { + delete process.env.VOYAGE_API_KEY + vi.resetModules() + const { embedText } = await import('@/lib/claude/embed') + await expect(embedText('test')).rejects.toThrow('VOYAGE_API_KEY') + }) +})