diff --git a/chrome-extension/manifest.ts b/chrome-extension/manifest.ts index a18412f..252d48d 100644 --- a/chrome-extension/manifest.ts +++ b/chrome-extension/manifest.ts @@ -29,6 +29,9 @@ const manifest = { '', 'https://api.deepl.com/*', 'https://api-free.deepl.com/*', + 'https://api.anthropic.com/*', + 'https://api.deepseek.com/*', + 'https://api.openai.com/*', 'https://*.youtube.com/*', ...(!IS_FIREFOX ? ['https://api.elevenlabs.io/*'] : []), ], diff --git a/packages/storage/lib/impl/provider-credentials-storage.ts b/packages/storage/lib/impl/provider-credentials-storage.ts index 91fea28..c94aa88 100644 --- a/packages/storage/lib/impl/provider-credentials-storage.ts +++ b/packages/storage/lib/impl/provider-credentials-storage.ts @@ -1,7 +1,7 @@ import { createStorage, StorageEnum } from '../base/index.js'; import type { BaseStorageType } from '../base/index.js'; -type ProviderIdLike = 'google-free' | 'deepl' | 'openai-compatible'; +type ProviderIdLike = 'google-free' | 'deepl' | 'anthropic' | 'deepseek' | 'openai' | 'openai-compatible'; interface ProviderCredentialEntry { apiKey?: string; diff --git a/packages/storage/lib/impl/translation-settings-storage.ts b/packages/storage/lib/impl/translation-settings-storage.ts index 94f4f87..5cfd46e 100644 --- a/packages/storage/lib/impl/translation-settings-storage.ts +++ b/packages/storage/lib/impl/translation-settings-storage.ts @@ -1,7 +1,7 @@ import { createStorage, StorageEnum } from '../base/index.js'; import type { BaseStorageType, ValueOrUpdateType } from '../base/index.js'; -type ProviderIdType = 'google-free' | 'deepl' | 'openai-compatible'; +type ProviderIdType = 'google-free' | 'deepl' | 'anthropic' | 'deepseek' | 'openai' | 'openai-compatible'; type DisplayStyleType = 'block' | 'replace'; diff --git a/packages/translation/lib/providers/anthropic.ts b/packages/translation/lib/providers/anthropic.ts new file mode 100644 index 0000000..b8d02e9 --- /dev/null +++ b/packages/translation/lib/providers/anthropic.ts @@ -0,0 +1,112 @@ +import { DEFAULT_SYSTEM_PROMPT_TEMPLATE, expandTemplate } from './openai-compatible.js'; +import { TranslationError } from '../types.js'; +import Anthropic from '@anthropic-ai/sdk'; +import type { ProviderCredential, TranslateRequest, TranslationProvider, ValidateResult } from '../types.js'; + +const DEFAULT_MODEL = 'claude-opus-4-8'; +const MAX_TOKENS = 8192; + +const toTranslationError = (err: unknown): TranslationError => { + if (err instanceof TranslationError) return err; + if (err instanceof Anthropic.AuthenticationError || err instanceof Anthropic.PermissionDeniedError) { + return new TranslationError('AUTH', err.message, err.status); + } + if (err instanceof Anthropic.RateLimitError) return new TranslationError('RATE_LIMIT', err.message, err.status); + // APIConnectionError is a subclass of APIError in the TypeScript SDK — check it first. + if (err instanceof Anthropic.APIConnectionError) return new TranslationError('NETWORK', err.message); + if (err instanceof Anthropic.APIError) { + return new TranslationError('HTTP_ERROR', `Anthropic ${err.status}: ${err.message}`, err.status); + } + if ((err as Error).name === 'AbortError') return new TranslationError('ABORTED', 'Request aborted'); + return new TranslationError('NETWORK', (err as Error).message); +}; + +const textFrom = (message: Anthropic.Message): string => { + if (message.stop_reason === 'refusal') { + throw new TranslationError('HTTP_ERROR', 'Anthropic declined to process this text'); + } + const text = message.content + .filter((block): block is Anthropic.TextBlock => block.type === 'text') + .map(block => block.text) + .join('') + .trim(); + if (!text) throw new TranslationError('PARSE', 'Empty response from Anthropic'); + return text; +}; + +export const createAnthropicProvider = (cred: ProviderCredential): TranslationProvider => { + const model = cred.model?.trim() || DEFAULT_MODEL; + + const makeClient = (apiKey: string): Anthropic => + new Anthropic({ + apiKey, + // The extension's background service worker is a browser context; host + // permissions in the manifest scope which origins we can reach. + dangerouslyAllowBrowser: true, + }); + + return { + id: 'anthropic', + maxTextsPerRequest: 1, + softMaxCharsPerRequest: 6_000, + preservesHtml: true, + credentialFields: ['apiKey'], + + async translate(req: TranslateRequest): Promise { + if (req.texts.length === 0) return []; + const apiKey = cred.apiKey?.trim() ?? ''; + if (!apiKey) throw new TranslationError('NO_API_KEY', 'Anthropic API key is not configured'); + + const userTemplate = cred.systemPrompt?.trim(); + const template = userTemplate && userTemplate.length > 0 ? userTemplate : DEFAULT_SYSTEM_PROMPT_TEMPLATE; + const system = expandTemplate(template, req.targetLang); + const client = makeClient(apiKey); + + const out: string[] = []; + for (let i = 0; i < req.texts.length; i += 1) { + const params: Anthropic.MessageCreateParamsNonStreaming = { + model, + max_tokens: MAX_TOKENS, + system, + messages: [{ role: 'user', content: req.texts[i] }], + }; + try { + if (req.onPartial) { + const onPartial = req.onPartial; + const index = i; + const stream = client.messages.stream(params, { signal: req.signal }); + let accumulated = ''; + stream.on('text', delta => { + accumulated += delta; + onPartial(index, accumulated); + }); + out.push(textFrom(await stream.finalMessage())); + } else { + out.push(textFrom(await client.messages.create(params, { signal: req.signal }))); + } + } catch (err) { + throw toTranslationError(err); + } + } + return out; + }, + + async validate(c: ProviderCredential): Promise { + const apiKey = c.apiKey?.trim() ?? ''; + if (!apiKey) return { ok: false, message: 'API key is required' }; + try { + // Token counting is free and verifies both the key and the model id. + await makeClient(apiKey).messages.countTokens({ + model: c.model?.trim() || DEFAULT_MODEL, + messages: [{ role: 'user', content: 'ping' }], + }); + return { ok: true }; + } catch (err) { + const e = toTranslationError(err); + if (e.code === 'AUTH') return { ok: false, message: 'Invalid API key' }; + if (e.status === 404) return { ok: false, message: `Model not found: ${c.model?.trim() || DEFAULT_MODEL}` }; + return { ok: false, message: e.message }; + } + }, + }; +}; diff --git a/packages/translation/lib/providers/openai-compatible.ts b/packages/translation/lib/providers/openai-compatible.ts index 9da7317..5f17a5f 100644 --- a/packages/translation/lib/providers/openai-compatible.ts +++ b/packages/translation/lib/providers/openai-compatible.ts @@ -1,5 +1,26 @@ import { TranslationError } from '../types.js'; -import type { ProviderCredential, TranslateRequest, TranslationProvider, ValidateResult } from '../types.js'; +import type { + CredentialField, + ProviderCredential, + ProviderId, + TranslateRequest, + TranslationProvider, + ValidateResult, +} from '../types.js'; + +/** Shape of a chat-completions-style provider. `openai-compatible` lets the + * user supply any base URL; the DeepSeek and OpenAI presets pin it. */ +interface ChatProviderConfig { + id: ProviderId; + /** Display name used in error messages. */ + label: string; + /** When set, the credential's baseUrl is ignored. */ + fixedBaseUrl?: string; + /** Used when the credential does not specify a model. */ + defaultModel?: string; + /** Fields the session-level credential check treats as required. */ + credentialFields: CredentialField[]; +} const langLabel = (code: string): string => { const c = code.toLowerCase(); @@ -153,13 +174,14 @@ const readStream = async (res: Response, onDelta: (accumulated: string) => void) const callChat = async ( cred: ProviderCredential, + config: ChatProviderConfig, systemPrompt: string, userText: string, signal?: AbortSignal, onDelta?: (accumulated: string) => void, ): Promise => { - const baseUrl = trimUrl(cred.baseUrl?.trim() ?? ''); - const model = cred.model?.trim() ?? ''; + const baseUrl = config.fixedBaseUrl ?? trimUrl(cred.baseUrl?.trim() ?? ''); + const model = cred.model?.trim() || config.defaultModel || ''; const apiKey = cred.apiKey?.trim() ?? ''; if (!baseUrl) throw new TranslationError('NO_API_KEY', 'Base URL is not configured'); if (!model) throw new TranslationError('NO_API_KEY', 'Model is not configured'); @@ -198,7 +220,7 @@ const callChat = async ( // ignore } const code = res.status === 401 || res.status === 403 ? 'AUTH' : res.status === 429 ? 'RATE_LIMIT' : 'HTTP_ERROR'; - throw new TranslationError(code, `OpenAI ${res.status}: ${detail || res.statusText}`, res.status); + throw new TranslationError(code, `${config.label} ${res.status}: ${detail || res.statusText}`, res.status); } if (onDelta) return (await readStream(res, onDelta)).trim(); @@ -211,12 +233,12 @@ const callChat = async ( return content.trim(); }; -export const createOpenAICompatibleProvider = (cred: ProviderCredential): TranslationProvider => ({ - id: 'openai-compatible', +const createChatCompletionsProvider = (cred: ProviderCredential, config: ChatProviderConfig): TranslationProvider => ({ + id: config.id, maxTextsPerRequest: 1, softMaxCharsPerRequest: 6_000, preservesHtml: true, - credentialFields: ['baseUrl', 'model', 'apiKey', 'systemPrompt'], + credentialFields: config.credentialFields, async translate(req: TranslateRequest): Promise { if (req.texts.length === 0) return []; @@ -227,18 +249,19 @@ export const createOpenAICompatibleProvider = (cred: ProviderCredential): Transl for (let i = 0; i < req.texts.length; i += 1) { const onPartial = req.onPartial; const onDelta = onPartial ? (text: string) => onPartial(i, text) : undefined; - out.push(await callChat(cred, systemPrompt, req.texts[i], req.signal, onDelta)); + out.push(await callChat(cred, config, systemPrompt, req.texts[i], req.signal, onDelta)); } return out; }, async validate(c: ProviderCredential): Promise { - const baseUrl = trimUrl(c.baseUrl?.trim() ?? ''); - if (!baseUrl) return { ok: false, message: 'Base URL is required' }; - if (!c.model?.trim()) return { ok: false, message: 'Model is required' }; + if (!config.fixedBaseUrl && !trimUrl(c.baseUrl?.trim() ?? '')) { + return { ok: false, message: 'Base URL is required' }; + } + if (!config.defaultModel && !c.model?.trim()) return { ok: false, message: 'Model is required' }; try { // 1-token translation to verify auth + model in one call. - const result = await callChat(c, 'You are a translator.', 'ping', undefined); + const result = await callChat(c, config, 'You are a translator.', 'ping', undefined); return result.length > 0 ? { ok: true } : { ok: false, message: 'Empty response' }; } catch (err) { const e = err as { code?: string; message?: string }; @@ -248,4 +271,29 @@ export const createOpenAICompatibleProvider = (cred: ProviderCredential): Transl }, }); -export { DEFAULT_SYSTEM_PROMPT_TEMPLATE }; +export const createOpenAICompatibleProvider = (cred: ProviderCredential): TranslationProvider => + createChatCompletionsProvider(cred, { + id: 'openai-compatible', + label: 'OpenAI-compatible', + credentialFields: ['baseUrl', 'model', 'apiKey', 'systemPrompt'], + }); + +export const createOpenAIProvider = (cred: ProviderCredential): TranslationProvider => + createChatCompletionsProvider(cred, { + id: 'openai', + label: 'OpenAI', + fixedBaseUrl: 'https://api.openai.com/v1', + defaultModel: 'gpt-4o-mini', + credentialFields: ['apiKey'], + }); + +export const createDeepSeekProvider = (cred: ProviderCredential): TranslationProvider => + createChatCompletionsProvider(cred, { + id: 'deepseek', + label: 'DeepSeek', + fixedBaseUrl: 'https://api.deepseek.com', + defaultModel: 'deepseek-chat', + credentialFields: ['apiKey'], + }); + +export { DEFAULT_SYSTEM_PROMPT_TEMPLATE, expandTemplate }; diff --git a/packages/translation/lib/registry.ts b/packages/translation/lib/registry.ts index d1dc0d9..280aeca 100644 --- a/packages/translation/lib/registry.ts +++ b/packages/translation/lib/registry.ts @@ -1,6 +1,11 @@ +import { createAnthropicProvider } from './providers/anthropic.js'; import { createDeepLProvider } from './providers/deepl.js'; import { createGoogleFreeProvider } from './providers/google-free.js'; -import { createOpenAICompatibleProvider } from './providers/openai-compatible.js'; +import { + createDeepSeekProvider, + createOpenAICompatibleProvider, + createOpenAIProvider, +} from './providers/openai-compatible.js'; import type { CredentialField, ProviderCredential, ProviderId, TranslationProvider } from './types.js'; export const getProvider = (id: ProviderId, cred: ProviderCredential): TranslationProvider => { @@ -9,6 +14,12 @@ export const getProvider = (id: ProviderId, cred: ProviderCredential): Translati return createGoogleFreeProvider(); case 'deepl': return createDeepLProvider(cred.apiKey ?? ''); + case 'anthropic': + return createAnthropicProvider(cred); + case 'deepseek': + return createDeepSeekProvider(cred); + case 'openai': + return createOpenAIProvider(cred); case 'openai-compatible': return createOpenAICompatibleProvider(cred); default: { @@ -45,14 +56,40 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [ credentialFields: ['apiKey'], }, { - id: 'openai-compatible', + id: 'anthropic', + name: 'Anthropic', + tier: 'Claude', + endpoint: 'api.anthropic.com', + defaults: { + model: 'claude-opus-4-8', + }, + credentialFields: ['model', 'apiKey', 'systemPrompt'], + }, + { + id: 'deepseek', + name: 'DeepSeek', + tier: 'Chat', + endpoint: 'api.deepseek.com', + defaults: { + model: 'deepseek-chat', + }, + credentialFields: ['model', 'apiKey', 'systemPrompt'], + }, + { + id: 'openai', name: 'OpenAI', - tier: 'Compatible', + tier: 'GPT', endpoint: 'api.openai.com', defaults: { - baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini', }, + credentialFields: ['model', 'apiKey', 'systemPrompt'], + }, + { + id: 'openai-compatible', + name: 'Custom', + tier: 'OpenAI-compatible', + endpoint: 'custom endpoint', credentialFields: ['baseUrl', 'model', 'apiKey', 'systemPrompt'], }, ]; diff --git a/packages/translation/lib/types.ts b/packages/translation/lib/types.ts index 516610b..a9a691b 100644 --- a/packages/translation/lib/types.ts +++ b/packages/translation/lib/types.ts @@ -1,4 +1,4 @@ -export type ProviderId = 'google-free' | 'deepl' | 'openai-compatible'; +export type ProviderId = 'google-free' | 'deepl' | 'anthropic' | 'deepseek' | 'openai' | 'openai-compatible'; export type CredentialField = 'apiKey' | 'baseUrl' | 'model' | 'systemPrompt'; diff --git a/packages/translation/package.json b/packages/translation/package.json index aa2a217..d5f89cf 100644 --- a/packages/translation/package.json +++ b/packages/translation/package.json @@ -23,5 +23,8 @@ }, "devDependencies": { "@extension/tsconfig": "workspace:*" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.111.0" } } diff --git a/pages/options/src/Options.tsx b/pages/options/src/Options.tsx index 5a115c7..9febc4c 100644 --- a/pages/options/src/Options.tsx +++ b/pages/options/src/Options.tsx @@ -260,6 +260,45 @@ const ProviderGlyph = ({ id, color = ACCENT, size = 15 }: { id: ProviderId; colo ); } + if (id === 'anthropic') { + return ( + + ); + } + if (id === 'deepseek') { + return ( + + ); + } + if (id === 'openai-compatible') { + return ( + + ); + } return ( ); } + if (id === 'anthropic') { + // Anthropic: A-frame mark + return ( + + ); + } + if (id === 'deepseek') { + // DeepSeek: wave with a spout dot + return ( + + ); + } + if (id === 'openai-compatible') { + // Custom endpoint: slider knobs + return ( + + ); + } // OpenAI: simplified "Blossom" — three interlocking ellipses rotated around center return (