Add Anthropic API integration, remove locale settings UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m15s

Anthropic API:
- Add @anthropic-ai/sdk with adapter wrapping OpenAI-shaped interface
- Support Claude models (opus, sonnet, haiku) with extended thinking
- Auto-reset model on provider switch, JSON retry logic
- Add Claude model pricing to ai-usage tracker
- Update AI settings form with Anthropic provider option
- Add provider field to AIUsageLog for cross-provider cost tracking

Locale Settings Removal:
- Strip Localization tab from admin settings (mobile + desktop)
- Remove i18n settings from router and feature flags
- Remove LOCALIZATION from SettingCategory enum
- Keep franc document language detection intact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 17:26:59 +01:00
parent 161cd1684a
commit f42b452899
9 changed files with 453 additions and 213 deletions

View File

@@ -1,10 +1,36 @@
import OpenAI from 'openai'
import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions'
import Anthropic from '@anthropic-ai/sdk'
import { prisma } from './prisma'
// Hardcoded Claude model list (Anthropic API doesn't expose a models.list endpoint for all users)
export const ANTHROPIC_CLAUDE_MODELS = [
'claude-opus-4-5-20250514',
'claude-sonnet-4-5-20250514',
'claude-haiku-3-5-20241022',
'claude-opus-4-20250514',
'claude-sonnet-4-20250514',
] as const
/**
* AI client type returned by getOpenAI().
* Both the OpenAI SDK and the Anthropic adapter satisfy this interface.
* All AI services only use .chat.completions.create(), so this is safe.
*/
export type AIClient = OpenAI | AnthropicClientAdapter
type AnthropicClientAdapter = {
__isAnthropicAdapter: true
chat: {
completions: {
create(params: ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion>
}
}
}
// OpenAI client singleton with lazy initialization
const globalForOpenAI = globalThis as unknown as {
openai: OpenAI | undefined
openai: AIClient | undefined
openaiInitialized: boolean
}
@@ -12,15 +38,17 @@ const globalForOpenAI = globalThis as unknown as {
/**
* Get the configured AI provider from SystemSettings.
* Returns 'openai' (default) or 'litellm' (ChatGPT subscription proxy).
* Returns 'openai' (default), 'litellm' (ChatGPT subscription proxy), or 'anthropic' (Claude API).
*/
export async function getConfiguredProvider(): Promise<'openai' | 'litellm'> {
export async function getConfiguredProvider(): Promise<'openai' | 'litellm' | 'anthropic'> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'ai_provider' },
})
const value = setting?.value || 'openai'
return value === 'litellm' ? 'litellm' : 'openai'
if (value === 'litellm') return 'litellm'
if (value === 'anthropic') return 'anthropic'
return 'openai'
} catch {
return 'openai'
}
@@ -219,6 +247,20 @@ async function getOpenAIApiKey(): Promise<string | null> {
}
}
/**
* Get Anthropic API key from SystemSettings
*/
async function getAnthropicApiKey(): Promise<string | null> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'anthropic_api_key' },
})
return setting?.value || process.env.ANTHROPIC_API_KEY || null
} catch {
return process.env.ANTHROPIC_API_KEY || null
}
}
/**
* Get custom base URL for OpenAI-compatible providers.
* Supports OpenRouter, Together AI, Groq, local models, etc.
@@ -265,15 +307,165 @@ async function createOpenAIClient(): Promise<OpenAI | null> {
}
/**
* Get the OpenAI client singleton
* Returns null if API key is not configured
* Check if a model is a Claude Opus model (supports extended thinking).
*/
export async function getOpenAI(): Promise<OpenAI | null> {
function isClaudeOpusModel(model: string): boolean {
return model.toLowerCase().includes('opus')
}
/**
* Create an Anthropic adapter that wraps the Anthropic SDK behind the
* same `.chat.completions.create()` surface as OpenAI. This allows all
* AI service files to work with zero changes.
*/
async function createAnthropicAdapter(): Promise<AnthropicClientAdapter | null> {
const apiKey = await getAnthropicApiKey()
if (!apiKey) {
console.warn('Anthropic API key not configured')
return null
}
const baseURL = await getBaseURL()
const anthropic = new Anthropic({
apiKey,
...(baseURL ? { baseURL } : {}),
})
if (baseURL) {
console.log(`[Anthropic] Using custom base URL: ${baseURL}`)
}
return {
__isAnthropicAdapter: true,
chat: {
completions: {
async create(params: ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion> {
// Extract system messages → Anthropic's system parameter
const systemMessages: string[] = []
const userAssistantMessages: Anthropic.MessageParam[] = []
for (const msg of params.messages) {
const content = typeof msg.content === 'string' ? msg.content : ''
if (msg.role === 'system' || msg.role === 'developer') {
systemMessages.push(content)
} else {
userAssistantMessages.push({
role: msg.role === 'assistant' ? 'assistant' : 'user',
content,
})
}
}
// Ensure messages start with a user message (Anthropic requirement)
if (userAssistantMessages.length === 0 || userAssistantMessages[0].role !== 'user') {
userAssistantMessages.unshift({ role: 'user', content: 'Hello' })
}
// Determine max_tokens (required by Anthropic, default 16384)
const maxTokens = params.max_tokens ?? params.max_completion_tokens ?? 16384
// Build Anthropic request
const anthropicParams: Anthropic.MessageCreateParamsNonStreaming = {
model: params.model,
max_tokens: maxTokens,
messages: userAssistantMessages,
...(systemMessages.length > 0 ? { system: systemMessages.join('\n\n') } : {}),
}
// Add temperature if present (Anthropic supports 0-1)
if (params.temperature !== undefined && params.temperature !== null) {
anthropicParams.temperature = params.temperature
}
// Extended thinking for Opus models
if (isClaudeOpusModel(params.model)) {
anthropicParams.thinking = { type: 'enabled', budget_tokens: Math.min(8192, maxTokens - 1) }
}
// Call Anthropic API
let response = await anthropic.messages.create(anthropicParams)
// Extract text from response (skip thinking blocks)
let responseText = response.content
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
.map((block) => block.text)
.join('')
// JSON retry: if response_format was set but response isn't valid JSON
const wantsJson = params.response_format && 'type' in params.response_format && params.response_format.type === 'json_object'
if (wantsJson && responseText) {
try {
JSON.parse(responseText)
} catch {
// Retry once with explicit JSON instruction
const retryMessages = [...userAssistantMessages]
const lastIdx = retryMessages.length - 1
if (lastIdx >= 0 && retryMessages[lastIdx].role === 'user') {
retryMessages[lastIdx] = {
...retryMessages[lastIdx],
content: retryMessages[lastIdx].content + '\n\nIMPORTANT: You MUST respond with valid JSON only. No markdown, no extra text, just a JSON object or array.',
}
}
const retryParams: Anthropic.MessageCreateParamsNonStreaming = {
...anthropicParams,
messages: retryMessages,
}
response = await anthropic.messages.create(retryParams)
responseText = response.content
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
.map((block) => block.text)
.join('')
}
}
// Normalize response to OpenAI shape
return {
id: response.id,
object: 'chat.completion' as const,
created: Math.floor(Date.now() / 1000),
model: response.model,
choices: [
{
index: 0,
message: {
role: 'assistant' as const,
content: responseText || null,
refusal: null,
},
finish_reason: response.stop_reason === 'end_turn' || response.stop_reason === 'stop_sequence' ? 'stop' : response.stop_reason === 'max_tokens' ? 'length' : 'stop',
logprobs: null,
},
],
usage: {
prompt_tokens: response.usage.input_tokens,
completion_tokens: response.usage.output_tokens,
total_tokens: response.usage.input_tokens + response.usage.output_tokens,
prompt_tokens_details: undefined as any,
completion_tokens_details: undefined as any,
},
}
},
},
},
}
}
/**
* Get the AI client singleton.
* Returns an OpenAI client or an Anthropic adapter (both expose .chat.completions.create()).
* Returns null if the API key is not configured.
*/
export async function getOpenAI(): Promise<AIClient | null> {
if (globalForOpenAI.openaiInitialized) {
return globalForOpenAI.openai || null
}
const client = await createOpenAIClient()
const provider = await getConfiguredProvider()
const client = provider === 'anthropic'
? await createAnthropicAdapter()
: await createOpenAIClient()
if (process.env.NODE_ENV !== 'production') {
globalForOpenAI.openai = client || undefined
@@ -298,10 +490,13 @@ export function resetOpenAIClient(): void {
export async function isOpenAIConfigured(): Promise<boolean> {
const provider = await getConfiguredProvider()
if (provider === 'litellm') {
// LiteLLM just needs a base URL configured
const baseURL = await getBaseURL()
return !!baseURL
}
if (provider === 'anthropic') {
const apiKey = await getAnthropicApiKey()
return !!apiKey
}
const apiKey = await getOpenAIApiKey()
return !!apiKey
}
@@ -327,6 +522,18 @@ export async function listAvailableModels(): Promise<{
}
}
// Anthropic: return hardcoded Claude model list
if (provider === 'anthropic') {
const apiKey = await getAnthropicApiKey()
if (!apiKey) {
return { success: false, error: 'Anthropic API key not configured' }
}
return {
success: true,
models: [...ANTHROPIC_CLAUDE_MODELS],
}
}
const client = await getOpenAI()
if (!client) {
@@ -336,7 +543,7 @@ export async function listAvailableModels(): Promise<{
}
}
const response = await client.models.list()
const response = await (client as OpenAI).models.list()
const chatModels = response.data
.filter((m) => m.id.includes('gpt') || m.id.includes('o1') || m.id.includes('o3') || m.id.includes('o4'))
.map((m) => m.id)
@@ -367,14 +574,16 @@ export async function validateModel(modelId: string): Promise<{
if (!client) {
return {
valid: false,
error: 'OpenAI API key not configured',
error: 'AI API key not configured',
}
}
// Try a minimal completion with the model using correct parameters
const provider = await getConfiguredProvider()
// For Anthropic, use minimal max_tokens
const params = buildCompletionParams(modelId, {
messages: [{ role: 'user', content: 'test' }],
maxTokens: 1,
maxTokens: provider === 'anthropic' ? 16 : 1,
})
await client.chat.completions.create(params)
@@ -407,11 +616,13 @@ export async function testOpenAIConnection(): Promise<{
}> {
try {
const client = await getOpenAI()
const provider = await getConfiguredProvider()
if (!client) {
const label = provider === 'anthropic' ? 'Anthropic' : 'OpenAI'
return {
success: false,
error: 'OpenAI API key not configured',
error: `${label} API key not configured`,
}
}
@@ -421,7 +632,7 @@ export async function testOpenAIConnection(): Promise<{
// Test with the configured model using correct parameters
const params = buildCompletionParams(configuredModel, {
messages: [{ role: 'user', content: 'Hello' }],
maxTokens: 5,
maxTokens: provider === 'anthropic' ? 16 : 5,
})
const response = await client.chat.completions.create(params)
@@ -436,7 +647,7 @@ export async function testOpenAIConnection(): Promise<{
const configuredModel = await getConfiguredModel()
// Check for model-specific errors
if (message.includes('does not exist') || message.includes('model_not_found')) {
if (message.includes('does not exist') || message.includes('model_not_found') || message.includes('not_found_error')) {
return {
success: false,
error: `Model "${configuredModel}" is not available. Check Settings → AI to select a valid model.`,