Vercel ai gateway (#7)

* feat: use vercel ai gateway

* chore: update UI
This commit is contained in:
2025-10-11 12:15:30 +02:00
committed by GitHub
parent ff84b75847
commit 7c9635ba83
14 changed files with 2348 additions and 1809 deletions

View File

@@ -1,3 +1,2 @@
DATABASE_URL="" DATABASE_URL=""
USER_KEY="" USER_KEY=""
ANTHROPIC_API_KEY=""

View File

@@ -1,3 +1,2 @@
DATABASE_URL="" DATABASE_URL=""
USER_KEY="test-key-123" USER_KEY="test-key-123"
ANTHROPIC_API_KEY=""

View File

@@ -4,12 +4,7 @@
"es2021": true, "es2021": true,
"jest": true "jest": true
}, },
"extends": [ "extends": ["next/core-web-vitals", "eslint:recommended", "prettier"],
"next/core-web-vitals",
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"parserOptions": { "parserOptions": {
"ecmaVersion": "latest", "ecmaVersion": "latest",
@@ -18,6 +13,6 @@
"plugins": ["@typescript-eslint"], "plugins": ["@typescript-eslint"],
"rules": { "rules": {
"@typescript-eslint/no-unused-vars": "error", "@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/consistent-type-definitions": "error" "no-console": "off"
} }
} }

View File

@@ -12,7 +12,7 @@ A versatile backend service that extends Siri's capabilities through custom shor
- **Framework**: Next.js 15 - **Framework**: Next.js 15
- **Language**: TypeScript - **Language**: TypeScript
- **AI Integration**: Anthropic's Claude API - **AI Integration**: Anthropic API via Vercel AI Gateway
- **Testing**: Jest - **Testing**: Jest
- **Deployment**: Vercel - **Deployment**: Vercel
- **Code Quality**: ESLint, Prettier, Husky - **Code Quality**: ESLint, Prettier, Husky
@@ -32,9 +32,7 @@ A versatile backend service that extends Siri's capabilities through custom shor
``` ```
Fill in: Fill in:
- `USER_KEY`: Your API authentication key - `USER_KEY`: Your API authentication key
- `ANTHROPIC_API_KEY`: Your Anthropic API key for Claude AI
3. **Run development server** 3. **Run development server**

View File

@@ -3,16 +3,35 @@ import { ShortcutsHandler } from '@utils/handler';
import { RequestSchema } from '@utils/types'; import { RequestSchema } from '@utils/types';
export async function POST(req: Request) { export async function POST(req: Request) {
const requestId = Math.random().toString(36).substring(7);
const startTime = Date.now();
console.info(
`[${requestId}] Incoming request at ${new Date().toISOString()}`
);
try { try {
const body = await req.json(); const body = await req.json();
console.info(`[${requestId}] Request body:`, {
command: body.command,
hasParameters: !!body.parameters,
parametersCount: body.parameters
? Object.keys(body.parameters).length
: 0,
hasApiKey: !!body.apiKey
});
const result = RequestSchema.safeParse(body); const result = RequestSchema.safeParse(body);
if (!result.success) { if (!result.success) {
console.warn(
`[${requestId}] Invalid request format:`,
result.error.issues
);
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
message: 'Invalid request format.', message: 'Invalid request format.',
errors: result.error.errors errors: result.error.issues
}, },
{ status: 400 } { status: 400 }
); );
@@ -22,6 +41,9 @@ export async function POST(req: Request) {
const isValid = shortcutsHandler.validateRequest(result.data); const isValid = shortcutsHandler.validateRequest(result.data);
if (!isValid) { if (!isValid) {
console.warn(
`[${requestId}] Unauthorized request for command: ${result.data.command}`
);
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
@@ -31,14 +53,25 @@ export async function POST(req: Request) {
); );
} }
console.info(`[${requestId}] Processing command: ${result.data.command}`);
const response = await shortcutsHandler.processCommand( const response = await shortcutsHandler.processCommand(
result.data.command, result.data.command,
result.data.parameters result.data.parameters
); );
const duration = Date.now() - startTime;
console.info(`[${requestId}] Request completed in ${duration}ms`, {
success: response.success,
hasData: !!response.data
});
return NextResponse.json(response); return NextResponse.json(response);
} catch (error) { } catch (error) {
console.error('Error processing shortcuts request:', error); const duration = Date.now() - startTime;
console.error(
`[${requestId}] Error processing request after ${duration}ms:`,
error
);
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,

View File

@@ -1,15 +1,12 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { ReactNode } from 'react';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'DiaryWhisper', title: 'DiaryWhisper',
description: 'Siri-enabled diary tracker for expenses and day logs' description: 'Siri-enabled diary tracker for expenses and day logs'
}; };
export default function RootLayout({ export default function RootLayout({ children }: { children: ReactNode }) {
children
}: {
children: React.ReactNode;
}) {
return ( return (
<html lang='en'> <html lang='en'>
<body>{children}</body> <body>{children}</body>

View File

@@ -7,7 +7,7 @@ export default function Home() {
<> <>
<div className='container'> <div className='container'>
<div className='header'> <div className='header'>
<h1>Siri Shortcuts v1.0.0</h1> <h1>Siri Shortcuts v1.1.0</h1>
<p>Anthropic-based power up for Siri Shortcuts</p> <p>Anthropic-based power up for Siri Shortcuts</p>
</div> </div>
@@ -20,7 +20,7 @@ export default function Home() {
<div className='status'> <div className='status'>
<span>Status: OPERATIONAL</span> <span>Status: OPERATIONAL</span>
<span>Last Backup: 2024-01-18 14:30 UTC</span> <span>Last Backup: 2025-10-11 10:00 UTC</span>
</div> </div>
</div> </div>

View File

@@ -24,36 +24,43 @@
"db:migrate": "prisma migrate dev" "db:migrate": "prisma migrate dev"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.33.1",
"@prisma/client": "^5.22.0", "@prisma/client": "^5.22.0",
"ai": "^5.0.68",
"axios": "^1.12.0", "axios": "^1.12.0",
"next": "^15.4.7", "next": "^15.4.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"zod": "^3.24.1" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^19.6.1", "@commitlint/cli": "^19.6.1",
"@commitlint/config-conventional": "^19.6.0", "@commitlint/config-conventional": "^19.6.0",
"@types/jest": "^29.5.14", "@eslint/compat": "^1.4.0",
"@types/node": "^22.10.2", "@eslint/eslintrc": "^3.3.1",
"@types/jest": "^30.0.0",
"@types/node": "^24.7.1",
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2", "@types/react-dom": "^19.0.2",
"@typescript-eslint/eslint-plugin": "^8.18.2", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^8.18.2", "@typescript-eslint/parser": "^6.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"dotenv": "^16.4.7", "dotenv": "^17.2.3",
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-next": "^15.1.3", "eslint-config-next": "^15.5.4",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"globals": "^16.4.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jest": "^29.7.0", "jest": "^30.2.0",
"lint-staged": "^15.3.0", "lint-staged": "^15.3.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.4.5",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.7.2" "typescript": "^5.9.3"
},
"resolutions": {
"@babel/helpers": ">=7.26.10",
"brace-expansion": ">=1.1.12"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx}": [ "*.{ts,tsx}": [

20
test-request.js Normal file
View File

@@ -0,0 +1,20 @@
require('dotenv').config();
const fetch = require('axios');
async function testAPI() {
try {
const response = await fetch.post('http://localhost:3000/api/shortcut', {
command: 'anthropic',
parameters: {
question: 'What is 42?'
},
apiKey: process.env.USER_KEY
});
console.log('Success:', response.data);
} catch (error) {
console.error('Error:', error.response?.data || error.message);
}
}
testAPI();

54
utils/aiGatewayClient.ts Normal file
View File

@@ -0,0 +1,54 @@
import { generateText } from 'ai';
interface AiGatewayResponse {
text: string;
tokensUsed: number;
}
export async function queryAiGateway(
text: string,
model: string
): Promise<AiGatewayResponse> {
const requestId = Math.random().toString(36).substring(7);
const startTime = Date.now();
console.info(`[AI-${requestId}] Starting Vercel Gateway AI request`, {
promptLength: text.length,
model,
timestamp: new Date().toISOString()
});
try {
const response = await generateText({
model,
prompt: text
});
const duration = Date.now() - startTime;
const tokensUsed = response.usage?.totalTokens || 0;
console.info(
`[AI-${requestId}] Vercel Gateway AI response received in ${duration}ms`,
{
responseLength: response.text.length,
tokensUsed,
usage: response.usage
}
);
return {
text: response.text,
tokensUsed
};
} catch (error) {
const duration = Date.now() - startTime;
console.error(
`[AI-${requestId}] Vercel Gateway AI error after ${duration}ms:`,
{
error: error instanceof Error ? error.message : String(error),
promptLength: text.length
}
);
throw new Error(`Vercel Gateway AI error: ${JSON.stringify(error)}.`);
}
}

View File

@@ -1,43 +0,0 @@
import Anthropic from '@anthropic-ai/sdk';
interface AnthropicResponse {
text: string;
tokensUsed: number;
}
export async function getMessage(text: string): Promise<AnthropicResponse> {
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY
});
console.info('Anthropic request with text: ', text);
const response = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 2048,
messages: [{ role: 'user', content: text }]
});
console.info('Anthropic response: ', response);
try {
const data = response.content as [{ type: string; text: string }];
const tokensUsed =
(response.usage?.input_tokens || 0) +
(response.usage?.output_tokens || 0);
console.info('Token usage:', {
input_tokens: response.usage?.input_tokens,
output_tokens: response.usage?.output_tokens,
total: tokensUsed
});
return {
text: data[0].text,
tokensUsed
};
} catch (error) {
throw new Error(`Anthropic client error: ${JSON.stringify(error)}.`);
}
}

View File

@@ -1,18 +1,27 @@
import { getMessage } from '@utils/anthropicClient'; import { queryAiGateway } from '@utils/aiGatewayClient';
import { ShortcutsResponse } from '../types'; import { ShortcutsResponse } from '../types';
import { dbOperations } from '@utils/db'; import { dbOperations } from '@utils/db';
export async function anthropicCommand( export async function anthropicCommand(
parameters: Record<string, string> | undefined parameters: Record<string, string> | undefined
): Promise<ShortcutsResponse> { ): Promise<ShortcutsResponse> {
const commandId = Math.random().toString(36).substring(7);
const startTime = Date.now();
let question = ''; let question = '';
let response = ''; let response = '';
let success = false; let success = false;
let errorMessage: string | undefined; let errorMessage: string | undefined;
let tokensUsed: number | undefined; let tokensUsed: number | undefined;
console.info(`[CMD-${commandId}] Anthropic command started`, {
hasParameters: !!parameters,
timestamp: new Date().toISOString()
});
try { try {
if (!parameters || !parameters['question']) { if (!parameters || !parameters['question']) {
console.warn(`[CMD-${commandId}] Missing question parameter`);
errorMessage = 'Need to provide a question.'; errorMessage = 'Need to provide a question.';
return { return {
success: false, success: false,
@@ -21,16 +30,35 @@ export async function anthropicCommand(
} }
question = parameters['question']; question = parameters['question'];
console.info(`[CMD-${commandId}] Processing question`, {
questionLength: question.length,
question:
question.substring(0, 100) + (question.length > 100 ? '...' : '')
});
const prompt = const prompt =
'I want to know ' + 'I want to know ' +
question + question +
'. Structure the response in a manner suitable for spoken communication.'; '. Structure the response in a manner suitable for spoken communication.';
const anthropicResponse = await getMessage(prompt); const anthropicResponse = await queryAiGateway(
prompt,
'anthropic/claude-sonnet-4.5'
);
response = anthropicResponse.text; response = anthropicResponse.text;
tokensUsed = anthropicResponse.tokensUsed; tokensUsed = anthropicResponse.tokensUsed;
success = true; success = true;
const duration = Date.now() - startTime;
console.info(
`[CMD-${commandId}] Anthropic command completed in ${duration}ms`,
{
responseLength: response.length,
tokensUsed,
success: true
}
);
return { return {
success: true, success: true,
message: response, message: response,
@@ -39,7 +67,11 @@ export async function anthropicCommand(
} }
}; };
} catch (error) { } catch (error) {
console.error('Anthropic command error:', error); const duration = Date.now() - startTime;
console.error(
`[CMD-${commandId}] Anthropic command failed after ${duration}ms:`,
error
);
success = false; success = false;
errorMessage = error instanceof Error ? error.message : 'Unknown error'; errorMessage = error instanceof Error ? error.message : 'Unknown error';
response = 'Sorry. There was a problem with Anthropic.'; response = 'Sorry. There was a problem with Anthropic.';
@@ -51,6 +83,7 @@ export async function anthropicCommand(
} finally { } finally {
if (question) { if (question) {
try { try {
console.info(`[CMD-${commandId}] Saving query to database`);
await dbOperations.saveAnthropicQuery({ await dbOperations.saveAnthropicQuery({
question, question,
response, response,
@@ -58,8 +91,12 @@ export async function anthropicCommand(
errorMessage, errorMessage,
tokensUsed tokensUsed
}); });
console.info(`[CMD-${commandId}] Query saved to database successfully`);
} catch (error) { } catch (error) {
console.error('Failed to log query to database:', error); console.error(
`[CMD-${commandId}] Failed to log query to database:`,
error
);
} }
} }
} }

View File

@@ -2,7 +2,7 @@ import { z } from 'zod';
export const RequestSchema = z.object({ export const RequestSchema = z.object({
command: z.string(), command: z.string(),
parameters: z.record(z.string()).optional(), parameters: z.record(z.string(), z.string()).optional(),
apiKey: z.string() apiKey: z.string()
}); });

3897
yarn.lock

File diff suppressed because it is too large Load Diff