Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions chrome-extension/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ const manifest = {
'<all_urls>',
'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/*'] : []),
],
Expand Down
2 changes: 1 addition & 1 deletion packages/storage/lib/impl/provider-credentials-storage.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/storage/lib/impl/translation-settings-storage.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
112 changes: 112 additions & 0 deletions packages/translation/lib/providers/anthropic.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<ValidateResult> {
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 };
}
},
};
};
74 changes: 61 additions & 13 deletions packages/translation/lib/providers/openai-compatible.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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<string> => {
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');
Expand Down Expand Up @@ -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();
Expand All @@ -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<string[]> {
if (req.texts.length === 0) return [];
Expand All @@ -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<ValidateResult> {
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 };
Expand All @@ -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 };
45 changes: 41 additions & 4 deletions packages/translation/lib/registry.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -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: {
Expand Down Expand Up @@ -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'],
},
];
Expand Down
2 changes: 1 addition & 1 deletion packages/translation/lib/types.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down
3 changes: 3 additions & 0 deletions packages/translation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@
},
"devDependencies": {
"@extension/tsconfig": "workspace:*"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.111.0"
}
}
39 changes: 39 additions & 0 deletions pages/options/src/Options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,45 @@ const ProviderGlyph = ({ id, color = ACCENT, size = 15 }: { id: ProviderId; colo
</svg>
);
}
if (id === 'anthropic') {
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true">
<path
d="M5 19 L12 5.5 L19 19 M8.2 13.5 h7.6"
fill="none"
stroke={color}
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
if (id === 'deepseek') {
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true">
<path
d="M3 14.5 C6 8.5, 10 8.5, 12 12 S18 16.5, 21 11"
fill="none"
stroke={color}
strokeWidth="1.8"
strokeLinecap="round"
/>
<circle cx="18.5" cy="6.5" r="1.7" fill={color} />
</svg>
);
}
if (id === 'openai-compatible') {
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true">
<g fill="none" stroke={color} strokeWidth="1.6" strokeLinecap="round">
<path d="M3.5 8 H20.5 M3.5 16 H20.5" />
</g>
<circle cx="9" cy="8" r="2.4" fill={color} />
<circle cx="15" cy="16" r="2.4" fill={color} />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true">
<g fill="none" stroke={color} strokeWidth="1.5" strokeLinecap="round">
Expand Down
Loading
Loading