diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2526e8..df119d0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Anthropic Compatible provider support for local or gateway endpoints that
+ implement the Anthropic Messages-style `/v1/messages` API, including model
+ fetching via `/v1/models`, optional Bearer auth, local-provider concurrency
+ limits, and options-page setup.
+
## [0.3.2] - 2026-05-31
### Added
diff --git a/README.md b/README.md
index bc61c37..994c97b 100644
--- a/README.md
+++ b/README.md
@@ -31,8 +31,8 @@ The extension is usable for normal article pages, legacy text-heavy pages, and s
- Handle legacy `table`, `font`, and `br`-separated pages.
- Avoid common non-reading areas such as navigation, forms, buttons, code blocks, hidden text, and page chrome.
- Use user-configured provider endpoints and API keys.
-- Support OpenAI, Anthropic Claude, and Google Gemini provider adapters.
-- Support local OpenAI-compatible runtimes such as LM Studio, Ollama, llama.cpp server, and omlx (Apple Silicon).
+- Support OpenAI, Anthropic Claude, Google Gemini, and compatible provider adapters.
+- Support local OpenAI-compatible runtimes such as LM Studio, Ollama, llama.cpp server, and omlx (Apple Silicon), plus Anthropic Messages API-compatible endpoints.
- Fetch provider model lists from the options page.
- Choose integrated or highlighted translation display styles.
- Optionally show a floating page button that starts translation only after the user clicks it.
@@ -82,13 +82,14 @@ Anthropic Claude: https://api.anthropic.com/v1/messages
Google Gemini: https://generativelanguage.googleapis.com/v1beta/models
```
-The endpoint field is shown only for OpenAI Compatible / Local LLM setups, where the user is expected to choose or enter a local endpoint.
+The endpoint field is shown only for compatible / Local LLM setups, where the user is expected to choose or enter a local endpoint.
The Fetch models action reads available models from the selected provider:
- OpenAI: `GET /v1/models`
- Anthropic Claude: `GET /v1/models`
- Google Gemini: `GET /v1beta/models`
+- OpenAI Compatible / Anthropic Compatible: `GET /v1/models`
Fetched models appear in the model selector. Margin keeps the currently configured model as an option when a provider default or previously saved model is not returned by the provider list.
@@ -108,34 +109,42 @@ Quoted posts are disabled by default and can be enabled from options. Posts that
## Local LLMs
-Margin supports local LLM runtimes through the OpenAI Compatible provider. This provider uses the OpenAI-style `/v1/chat/completions` API, allows an empty API key, and uses a lower default translation concurrency for local inference.
+Margin supports local LLM runtimes through compatible providers:
-Common endpoint presets:
+- OpenAI Compatible uses the OpenAI-style `/v1/chat/completions` API.
+- Anthropic Compatible uses the Anthropic Messages-style `/v1/messages` API with tool `input_schema` structured output. It is a wire-protocol option for compatible local or gateway endpoints, not a separate Anthropic-hosted service.
+
+Both compatible providers allow an empty API key and use lower default translation concurrency for local inference. If an Anthropic-compatible gateway requires a key, Margin sends it as `Authorization: Bearer ...`.
+
+Common compatible endpoints:
```text
LM Studio: http://localhost:1234/v1/chat/completions
Ollama: http://localhost:11434/v1/chat/completions
llama.cpp server: http://localhost:8080/v1/chat/completions
omlx: http://localhost:8000/v1/chat/completions
+Generic Anthropic-compatible: http://localhost:8000/v1/messages
+Ollama Anthropic compatibility: http://localhost:11434/v1/messages
```
To use a local runtime:
1. Start the local model server.
2. Open Margin options.
-3. Select OpenAI Compatible as the provider.
-4. Select an endpoint preset, or enter the endpoint URL shown by your runtime.
+3. Select OpenAI Compatible for `/v1/chat/completions`, or Anthropic Compatible for `/v1/messages`.
+4. Select an OpenAI-compatible endpoint preset, or enter the endpoint URL shown by your runtime.
5. Leave API key empty unless your local gateway requires one.
6. Click Fetch models and choose a served model from the model selector.
-7. Keep Request JSON mode enabled when supported. Disable it if the local runtime rejects the `response_format` request field.
+7. For OpenAI Compatible, keep Request JSON mode enabled when supported. Disable it if the local runtime rejects the `response_format` request field.
Runtime notes:
- LM Studio commonly serves OpenAI-compatible requests at `http://localhost:1234/v1/chat/completions`.
- Ollama requires its OpenAI-compatible API to be available at `http://localhost:11434/v1/chat/completions`.
+- Ollama can also expose Anthropic-compatible requests at `http://localhost:11434/v1/messages`. Margin sends tools for structured output but does not force `tool_choice` for Anthropic-compatible endpoints, because some compatible runtimes accept tools but do not support forced tool selection.
- llama.cpp server must be started with an OpenAI-compatible HTTP server enabled, commonly at `http://localhost:8080/v1/chat/completions`.
- omlx is an Apple Silicon MLX inference server. Start it with `omlx serve` (zero-config, models from `~/.omlx/models`) or `omlx serve --model-dir /path/to/models`; the OpenAI-compatible API becomes available at `http://localhost:8000/v1/chat/completions`.
-- If Fetch models fails, confirm the local server is running, the endpoint URL ends with `/v1/chat/completions`, and the runtime exposes a compatible `/v1/models` endpoint.
+- If Fetch models fails, confirm the local server is running, the endpoint URL ends with `/v1/chat/completions` or `/v1/messages`, and the runtime exposes a compatible `/v1/models` endpoint.
Local model quality, speed, context length, and JSON reliability depend on the model and runtime. Instruct models with strong multilingual ability are recommended for translation.
diff --git a/README.zh-TW.md b/README.zh-TW.md
index 240b860..3187284 100644
--- a/README.zh-TW.md
+++ b/README.zh-TW.md
@@ -22,8 +22,8 @@ Margin 目前仍是早期 MVP,支援 Chrome 與其他 Chromium 系瀏覽器,
- 支援舊式 `table`、`font`,以及以 `br` 分隔文字的頁面。
- 避開常見的非閱讀區域,例如導覽列、表單、按鈕、程式碼區塊、隱藏文字與頁面介面。
- 使用你自行設定的 provider endpoint 與 API key。
-- 支援 OpenAI、Anthropic Claude 與 Google Gemini provider adapter。
-- 支援本機 OpenAI-compatible runtime,例如 LM Studio、Ollama、llama.cpp server 與 omlx(Apple Silicon)。
+- 支援 OpenAI、Anthropic Claude、Google Gemini 與 compatible provider adapter。
+- 支援本機 OpenAI-compatible runtime,例如 LM Studio、Ollama、llama.cpp server 與 omlx(Apple Silicon),以及 Anthropic Messages API-compatible endpoint。
- 可從 options 頁面取得 provider 的模型列表。
- 可選擇融入原文或醒目提示的譯文顯示樣式。
- 可選擇在頁面顯示浮動翻譯按鈕,且只有使用者點擊後才開始翻譯。
@@ -66,13 +66,14 @@ Anthropic Claude: https://api.anthropic.com/v1/messages
Google Gemini: https://generativelanguage.googleapis.com/v1beta/models
```
-Endpoint 欄位只會在 OpenAI Compatible / Local LLM 設定中顯示,因為這些情境才需要使用者選擇或輸入本機 endpoint。
+Endpoint 欄位只會在 compatible / Local LLM 設定中顯示,因為這些情境才需要使用者選擇或輸入本機 endpoint。
Fetch models 會從目前選擇的 provider 讀取可用模型:
- OpenAI: `GET /v1/models`
- Anthropic Claude: `GET /v1/models`
- Google Gemini: `GET /v1beta/models`
+- OpenAI Compatible / Anthropic Compatible: `GET /v1/models`
取得的模型會出現在模型選單中。如果目前設定的 provider 預設模型或已儲存模型沒有出現在 provider 回傳的列表中,Margin 會保留它作為可選項目。
@@ -92,34 +93,42 @@ Quoted posts 預設不會翻譯,可在 options 中啟用。X 已標示為翻
## 本機 LLM
-Margin 透過 OpenAI Compatible provider 支援本機 LLM runtime。這個 provider 使用 OpenAI 風格的 `/v1/chat/completions` API,允許 API key 留空,並針對本機推理使用較低的預設翻譯 concurrency。
+Margin 透過 compatible provider 支援本機 LLM runtime:
-常見 endpoint preset:
+- OpenAI Compatible 使用 OpenAI 風格的 `/v1/chat/completions` API。
+- Anthropic Compatible 使用 Anthropic Messages 風格的 `/v1/messages` API,並透過 tool `input_schema` 取得結構化輸出。這是給相容本機或 gateway endpoint 使用的 wire-protocol 選項,不是另一個 Anthropic 官方代管服務。
+
+兩種 compatible provider 都允許 API key 留空,並針對本機推理使用較低的預設翻譯 concurrency。如果 Anthropic-compatible gateway 需要 key,Margin 會以 `Authorization: Bearer ...` 送出。
+
+常見 compatible endpoint:
```text
LM Studio: http://localhost:1234/v1/chat/completions
Ollama: http://localhost:11434/v1/chat/completions
llama.cpp server: http://localhost:8080/v1/chat/completions
omlx: http://localhost:8000/v1/chat/completions
+Generic Anthropic-compatible: http://localhost:8000/v1/messages
+Ollama Anthropic compatibility: http://localhost:11434/v1/messages
```
使用本機 runtime:
1. 啟動本機模型 server。
2. 開啟 Margin options。
-3. 選擇 OpenAI Compatible 作為 provider。
-4. 選擇 endpoint preset,或輸入你的 runtime 顯示的 endpoint URL。
+3. 如果 endpoint 是 `/v1/chat/completions`,選擇 OpenAI Compatible;如果 endpoint 是 `/v1/messages`,選擇 Anthropic Compatible。
+4. 選擇 OpenAI-compatible endpoint preset,或輸入你的 runtime 顯示的 endpoint URL。
5. 除非你的本機 gateway 需要 API key,否則 API key 可以留空。
6. 點擊 Fetch models,並從模型選單中選擇 server 提供的模型。
-7. 如果 runtime 支援,建議保持 Request JSON mode 啟用。若本機 runtime 拒絕 `response_format` request 欄位,請停用此選項。
+7. 對 OpenAI Compatible 而言,如果 runtime 支援,建議保持 Request JSON mode 啟用。若本機 runtime 拒絕 `response_format` request 欄位,請停用此選項。
Runtime 注意事項:
- LM Studio 通常在 `http://localhost:1234/v1/chat/completions` 提供 OpenAI-compatible request。
- Ollama 需要 OpenAI-compatible API 可在 `http://localhost:11434/v1/chat/completions` 使用。
+- Ollama 也可以在 `http://localhost:11434/v1/messages` 提供 Anthropic-compatible request。Margin 會送出 tools 以取得結構化輸出,但不會在 Anthropic-compatible endpoint 強制指定 `tool_choice`,因為部分相容 runtime 接受 tools,卻不支援 forced tool selection。
- llama.cpp server 必須啟動 OpenAI-compatible HTTP server,常見位址為 `http://localhost:8080/v1/chat/completions`。
- omlx 是 Apple Silicon 上的 MLX 推論 server。以 `omlx serve`(零設定,模型從 `~/.omlx/models` 載入)或 `omlx serve --model-dir /path/to/models` 啟動後,OpenAI-compatible API 預設位於 `http://localhost:8000/v1/chat/completions`。
-- 如果 Fetch models 失敗,請確認本機 server 已啟動、endpoint URL 以 `/v1/chat/completions` 結尾,且 runtime 有提供 compatible `/v1/models` endpoint。
+- 如果 Fetch models 失敗,請確認本機 server 已啟動、endpoint URL 以 `/v1/chat/completions` 或 `/v1/messages` 結尾,且 runtime 有提供 compatible `/v1/models` endpoint。
本機模型的品質、速度、context length 與 JSON 穩定性,取決於模型與 runtime。建議使用具備強多語能力的 instruct model 進行翻譯。
diff --git a/apps/extension/public/options.html b/apps/extension/public/options.html
index a3b8687..4875eca 100644
--- a/apps/extension/public/options.html
+++ b/apps/extension/public/options.html
@@ -22,6 +22,7 @@
Provider
Anthropic Claude
Google Gemini
OpenAI Compatible
+ Anthropic Compatible
@@ -37,10 +38,6 @@ Provider
Presets switch the provider to OpenAI Compatible and fill the endpoint.
-
- Endpoint URL
-
-
@@ -49,10 +46,22 @@ Provider
+
+ Local Anthropic-compatible endpoint
+
+ Use an Anthropic Messages API endpoint such as http://localhost:8000/v1/messages.
+
+
+
+ Endpoint URL
+
+
API key
- Paste the raw provider API key. Local OpenAI-compatible endpoints can leave this empty.
+
+ Paste the raw provider API key. Local OpenAI-compatible and Anthropic-compatible endpoints can leave this empty.
+
Model
diff --git a/apps/extension/src/background/providers/anthropic.test.ts b/apps/extension/src/background/providers/anthropic.test.ts
index df8bae8..511e680 100644
--- a/apps/extension/src/background/providers/anthropic.test.ts
+++ b/apps/extension/src/background/providers/anthropic.test.ts
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "../../shared/defaults";
import type { ExtensionSettings, TextSegment } from "../../shared/types";
-import { anthropicProvider } from "./anthropic";
+import { anthropicCompatibleProvider, anthropicProvider } from "./anthropic";
const segments: TextSegment[] = [
{ id: "a", text: "Hello" },
@@ -119,6 +119,109 @@ describe("anthropicProvider.translate", () => {
});
});
+describe("anthropicCompatibleProvider.translate", () => {
+ it("uses Bearer auth and omits browser-access header when api key is provided", async () => {
+ const body = JSON.stringify({
+ content: [
+ {
+ type: "tool_use",
+ name: "return_translations",
+ input: { translations: [{ id: "a", text: "你好" }] }
+ }
+ ]
+ });
+ const { fetch: stub, calls } = stubFetch(new Response(body, { status: 200 }));
+ vi.stubGlobal("fetch", stub);
+
+ const results = await anthropicCompatibleProvider.translate(
+ segments,
+ makeSettings({
+ provider: "anthropic-compatible",
+ apiKey: "test-key",
+ providerEndpoint: "http://localhost:8000/v1/messages"
+ })
+ );
+
+ expect(results).toEqual([{ id: "a", text: "你好" }]);
+ const headers = calls[0].init.headers as Record;
+ expect(headers.Authorization).toBe("Bearer test-key");
+ expect(headers["x-api-key"]).toBeUndefined();
+ expect(headers["anthropic-dangerous-direct-browser-access"]).toBeUndefined();
+ expect(headers["anthropic-version"]).toBe("2023-06-01");
+ expect(calls[0].body.tool_choice).toBeUndefined();
+ expect((calls[0].body.tools as Array<{ name: string }>)[0].name).toBe("return_translations");
+ });
+
+ it("omits Authorization header when api key is empty", async () => {
+ const body = JSON.stringify({
+ content: [
+ {
+ type: "tool_use",
+ name: "return_translations",
+ input: { translations: [{ id: "a", text: "你好" }] }
+ }
+ ]
+ });
+ const { fetch: stub, calls } = stubFetch(new Response(body, { status: 200 }));
+ vi.stubGlobal("fetch", stub);
+
+ const results = await anthropicCompatibleProvider.translate(
+ segments,
+ makeSettings({
+ provider: "anthropic-compatible",
+ apiKey: "",
+ providerEndpoint: "http://localhost:8000/v1/messages"
+ })
+ );
+
+ expect(results).toEqual([{ id: "a", text: "你好" }]);
+ const headers = calls[0].init.headers as Record;
+ expect(headers.Authorization).toBeUndefined();
+ expect(headers["x-api-key"]).toBeUndefined();
+ });
+});
+
+describe("anthropicCompatibleProvider.listModels", () => {
+ it("uses Bearer auth when api key is provided", async () => {
+ const body = JSON.stringify({ data: [{ id: "local-model" }] });
+ const { fetch: stub, calls } = stubFetch(new Response(body, { status: 200 }));
+ vi.stubGlobal("fetch", stub);
+
+ const models = await anthropicCompatibleProvider.listModels(
+ makeSettings({
+ provider: "anthropic-compatible",
+ apiKey: "cloud-key",
+ providerEndpoint: "http://localhost:8000/v1/messages"
+ })
+ );
+
+ expect(models).toEqual([{ id: "local-model" }]);
+ expect(calls[0].url).toBe("http://localhost:8000/v1/models");
+ const headers = calls[0].init.headers as Record;
+ expect(headers.Authorization).toBe("Bearer cloud-key");
+ expect(headers["x-api-key"]).toBeUndefined();
+ });
+
+ it("omits Authorization header when api key is empty", async () => {
+ const body = JSON.stringify({ data: [{ id: "local-model" }] });
+ const { fetch: stub, calls } = stubFetch(new Response(body, { status: 200 }));
+ vi.stubGlobal("fetch", stub);
+
+ const models = await anthropicCompatibleProvider.listModels(
+ makeSettings({
+ provider: "anthropic-compatible",
+ apiKey: "",
+ providerEndpoint: "http://localhost:8000/v1/messages"
+ })
+ );
+
+ expect(models).toEqual([{ id: "local-model" }]);
+ const headers = calls[0].init.headers as Record;
+ expect(headers.Authorization).toBeUndefined();
+ expect(headers["x-api-key"]).toBeUndefined();
+ });
+});
+
describe("anthropicProvider.listModels", () => {
it("returns models with display_name mapped to displayName", async () => {
const body = JSON.stringify({
diff --git a/apps/extension/src/background/providers/anthropic.ts b/apps/extension/src/background/providers/anthropic.ts
index c0cf5bf..43ae1d0 100644
--- a/apps/extension/src/background/providers/anthropic.ts
+++ b/apps/extension/src/background/providers/anthropic.ts
@@ -25,11 +25,17 @@ export const anthropicProvider: TranslationProvider = {
listModels: listAnthropicModels
};
+export const anthropicCompatibleProvider: TranslationProvider = {
+ id: "anthropic-compatible",
+ translate: translateWithAnthropic,
+ listModels: listAnthropicModels
+};
+
async function translateWithAnthropic(
segments: TextSegment[],
settings: ExtensionSettings
): Promise {
- const body = {
+ const body: AnthropicRequestBody = {
model: settings.model,
max_tokens: 4096,
temperature: 0,
@@ -41,9 +47,12 @@ async function translateWithAnthropic(
input_schema: getTranslationSchema() as Tool.InputSchema
}
],
- tool_choice: { type: "tool" as const, name: TRANSLATION_TOOL_NAME },
messages: [{ role: "user" as const, content: buildTranslationPayload(segments, settings) }]
- } satisfies AnthropicRequestBody;
+ };
+
+ if (settings.provider === "anthropic") {
+ body.tool_choice = { type: "tool", name: TRANSLATION_TOOL_NAME };
+ }
const response = await fetch(settings.providerEndpoint, {
method: "POST",
@@ -86,14 +95,22 @@ async function listAnthropicModels(settings: ExtensionSettings): Promise
typeof model.id === "string" && model.id.length > 0
)
- .map((model) => ({ id: model.id, displayName: model.display_name }));
+ .map((model) => ({ id: model.id, ...(model.display_name ? { displayName: model.display_name } : {}) }));
}
function buildAnthropicHeaders(settings: ExtensionSettings): Record {
+ if (settings.provider === "anthropic") {
+ return {
+ "Content-Type": "application/json",
+ "anthropic-version": ANTHROPIC_VERSION,
+ "x-api-key": settings.apiKey,
+ "anthropic-dangerous-direct-browser-access": "true"
+ };
+ }
+
return {
"Content-Type": "application/json",
- "x-api-key": settings.apiKey,
"anthropic-version": ANTHROPIC_VERSION,
- "anthropic-dangerous-direct-browser-access": "true"
+ ...(settings.apiKey ? { Authorization: `Bearer ${settings.apiKey}` } : {})
};
}
diff --git a/apps/extension/src/background/providers/index.test.ts b/apps/extension/src/background/providers/index.test.ts
index e678996..3358bc5 100644
--- a/apps/extension/src/background/providers/index.test.ts
+++ b/apps/extension/src/background/providers/index.test.ts
@@ -3,7 +3,7 @@ import type { TranslationProviderId } from "../../shared/types";
import { getProvider } from "./index";
describe("getProvider", () => {
- it.each(["openai", "openai-compatible", "anthropic", "google"])(
+ it.each(["openai", "openai-compatible", "anthropic", "anthropic-compatible", "google"])(
"returns a provider whose id matches the requested id (%s)",
(id) => {
const provider = getProvider(id);
diff --git a/apps/extension/src/background/providers/index.ts b/apps/extension/src/background/providers/index.ts
index 47f772b..f9a0565 100644
--- a/apps/extension/src/background/providers/index.ts
+++ b/apps/extension/src/background/providers/index.ts
@@ -1,5 +1,5 @@
import type { TranslationProviderId } from "../../shared/types";
-import { anthropicProvider } from "./anthropic";
+import { anthropicCompatibleProvider, anthropicProvider } from "./anthropic";
import { googleProvider } from "./google";
import { openaiCompatibleProvider, openaiProvider } from "./openai";
import type { TranslationProvider } from "./types";
@@ -10,6 +10,7 @@ const REGISTRY: Record = {
openai: openaiProvider,
"openai-compatible": openaiCompatibleProvider,
anthropic: anthropicProvider,
+ "anthropic-compatible": anthropicCompatibleProvider,
google: googleProvider
};
diff --git a/apps/extension/src/background/serviceWorker.test.ts b/apps/extension/src/background/serviceWorker.test.ts
index a82a10e..f9c0d18 100644
--- a/apps/extension/src/background/serviceWorker.test.ts
+++ b/apps/extension/src/background/serviceWorker.test.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { CACHE_KEY_PREFIX, DEFAULT_SETTINGS, SETTINGS_KEY } from "../shared/defaults";
+import { CACHE_KEY_PREFIX, DEFAULT_SETTINGS, PROVIDER_DEFAULTS, SETTINGS_KEY } from "../shared/defaults";
+import type { LocalTranslationProviderId } from "../shared/localProviders";
import type { ExtensionSettings, RuntimeMessage, TextSegment, TranslationResult } from "../shared/types";
import type * as ServiceWorkerModuleType from "./serviceWorker";
@@ -158,6 +159,19 @@ describe("handleMessage TRANSLATE_BATCH", () => {
});
}
+ function stubAnthropicResponse(translations: TranslationResult[]): void {
+ fetchResponder = () =>
+ jsonResponse({
+ content: [
+ {
+ type: "tool_use",
+ name: "return_translations",
+ input: { translations }
+ }
+ ]
+ });
+ }
+
it("returns an error when api key is missing for a non-local provider", async () => {
saveSettings({ provider: "openai", apiKey: "" });
const { handleMessage } = await loadModule();
@@ -169,16 +183,28 @@ describe("handleMessage TRANSLATE_BATCH", () => {
expect(fetchCalls).toHaveLength(0);
});
- it("allows openai-compatible without an api key", async () => {
- saveSettings({ provider: "openai-compatible", apiKey: "" });
- stubOpenAIResponse([{ id: "s1", text: "你好" }]);
- const { handleMessage } = await loadModule();
+ it.each(["openai-compatible", "anthropic-compatible"])(
+ "allows %s without an api key",
+ async (provider) => {
+ saveSettings({
+ provider,
+ apiKey: "",
+ providerEndpoint: PROVIDER_DEFAULTS[provider].providerEndpoint,
+ model: PROVIDER_DEFAULTS[provider].model
+ });
+ if (provider === "openai-compatible") {
+ stubOpenAIResponse([{ id: "s1", text: "你好" }]);
+ } else {
+ stubAnthropicResponse([{ id: "s1", text: "你好" }]);
+ }
+ const { handleMessage } = await loadModule();
- const result = (await handleMessage({ type: "TRANSLATE_BATCH", segments })) as TranslateBatchResult;
+ const result = (await handleMessage({ type: "TRANSLATE_BATCH", segments })) as TranslateBatchResult;
- expect(result.ok).toBe(true);
- expect(result.results).toEqual([{ id: "s1", text: "你好" }]);
- });
+ expect(result.ok).toBe(true);
+ expect(result.results).toEqual([{ id: "s1", text: "你好" }]);
+ }
+ );
it("dispatches through the registry and caches results in session and persistent stores", async () => {
saveSettings({ provider: "openai", apiKey: "sk-test", cacheMode: "persistent", targetLanguage: "繁體中文" });
@@ -254,6 +280,35 @@ describe("handleMessage LIST_MODELS", () => {
expect(result.error).toMatch(/Configure an API key/);
});
+ it.each(["openai-compatible", "anthropic-compatible"])(
+ "allows %s to list models without an api key",
+ async (provider) => {
+ fetchResponder = (url) => {
+ if (provider === "anthropic-compatible") {
+ expect(url).toBe("http://localhost:8000/v1/models");
+ return jsonResponse({ data: [{ id: "local-model" }] });
+ }
+ return jsonResponse({ data: [{ id: "gpt-4o-mini" }] });
+ };
+ const { handleMessage } = await loadModule();
+
+ const result = (await handleMessage({
+ type: "LIST_MODELS",
+ settings: makeSettings({
+ provider,
+ apiKey: "",
+ providerEndpoint: PROVIDER_DEFAULTS[provider].providerEndpoint,
+ model: PROVIDER_DEFAULTS[provider].model
+ })
+ })) as ListModelsResult;
+
+ expect(result.ok).toBe(true);
+ expect(result.models).toEqual([
+ { id: provider === "anthropic-compatible" ? "local-model" : "gpt-4o-mini" }
+ ]);
+ }
+ );
+
it("dispatches to the registered provider", async () => {
fetchResponder = () => jsonResponse({ data: [{ id: "gpt-4o" }, { id: "gpt-4o-mini" }] });
const { handleMessage } = await loadModule();
diff --git a/apps/extension/src/background/serviceWorker.ts b/apps/extension/src/background/serviceWorker.ts
index 3090a73..360e388 100644
--- a/apps/extension/src/background/serviceWorker.ts
+++ b/apps/extension/src/background/serviceWorker.ts
@@ -8,6 +8,7 @@ import type {
TranslationResult
} from "../shared/types";
import { hashText } from "../shared/hash";
+import { isLocalTranslationProvider } from "../shared/localProviders";
import { getProvider } from "./providers";
import { installUpgradeLifecycle } from "./upgradeLifecycle";
@@ -53,7 +54,7 @@ async function translateBatch(segments: TextSegment[]): Promise {
- if (!settings.apiKey && settings.provider !== "openai-compatible") {
+ if (!settings.apiKey && !isLocalTranslationProvider(settings.provider)) {
return { ok: false, error: "Configure an API key before fetching models." };
}
diff --git a/apps/extension/src/content/orchestrator.test.ts b/apps/extension/src/content/orchestrator.test.ts
index 190ab7f..d345ab9 100644
--- a/apps/extension/src/content/orchestrator.test.ts
+++ b/apps/extension/src/content/orchestrator.test.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "../shared/defaults";
+import type { LocalTranslationProviderId } from "../shared/localProviders";
import type { ExtensionSettings, RuntimeMessage, TextSegment, TranslationResult } from "../shared/types";
import { createOrchestrator, type ContentOrchestrator } from "./orchestrator";
import { TRANSLATION_CLASS, TRANSLATED_ATTR } from "./translationRenderer";
@@ -300,25 +301,28 @@ describe("createOrchestrator — error paths", () => {
});
describe("createOrchestrator — provider-specific queue", () => {
- it("uses smaller batch/concurrency for openai-compatible runtimes", async () => {
- useRouter({ settings: { ...DEFAULT_SETTINGS, provider: "openai-compatible" } });
-
- const paragraphs = Array.from({ length: 5 }, (_, i) => `${SAMPLE_PARAGRAPH} extra ${i}`);
- seedDocument(`${paragraphs.map((text) => `${text}
`).join("")} `);
- const orchestrator = createTestOrchestrator();
-
- await orchestrator.setEnabled(true);
- await vi.waitFor(() => {
- expect(document.querySelectorAll(`p[${TRANSLATED_ATTR}="done"]`).length).toBeGreaterThan(0);
- });
-
- const batches = sendMessageMock.mock.calls
- .map(([msg]) => msg)
- .filter((msg): msg is Extract => msg.type === "TRANSLATE_BATCH")
- .map((msg) => msg.segments.length);
- expect(batches.length).toBeGreaterThanOrEqual(2);
- expect(Math.max(...batches)).toBeLessThanOrEqual(3);
- });
+ it.each(["openai-compatible", "anthropic-compatible"])(
+ "uses smaller batch/concurrency for %s runtimes",
+ async (provider) => {
+ useRouter({ settings: { ...DEFAULT_SETTINGS, provider } });
+
+ const paragraphs = Array.from({ length: 5 }, (_, i) => `${SAMPLE_PARAGRAPH} extra ${i}`);
+ seedDocument(`${paragraphs.map((text) => `${text}
`).join("")} `);
+ const orchestrator = createTestOrchestrator();
+
+ await orchestrator.setEnabled(true);
+ await vi.waitFor(() => {
+ expect(document.querySelectorAll(`p[${TRANSLATED_ATTR}="done"]`).length).toBeGreaterThan(0);
+ });
+
+ const batches = sendMessageMock.mock.calls
+ .map(([msg]) => msg)
+ .filter((msg): msg is Extract => msg.type === "TRANSLATE_BATCH")
+ .map((msg) => msg.segments.length);
+ expect(batches.length).toBeGreaterThanOrEqual(2);
+ expect(Math.max(...batches)).toBeLessThanOrEqual(3);
+ }
+ );
});
describe("createOrchestrator — DOM observers", () => {
@@ -495,4 +499,30 @@ describe("createOrchestrator — debug state plumbing", () => {
extensionVersion: "0.3.2"
});
});
+
+ it("labels Anthropic-compatible debug sessions as tool schema requests", async () => {
+ useRouter({
+ settings: {
+ ...DEFAULT_SETTINGS,
+ debugMode: true,
+ provider: "anthropic-compatible",
+ model: "local-claude",
+ providerEndpoint: "http://user:pass@localhost:8000/v1/messages?token=secret#fragment"
+ }
+ });
+
+ seedDocument(`${SAMPLE_PARAGRAPH}
`);
+ const orchestrator = createTestOrchestrator();
+
+ await orchestrator.setEnabled(true);
+
+ expect(orchestrator.getDebugState().providerConfig).toEqual({
+ provider: "anthropic-compatible",
+ providerName: "Anthropic Compatible",
+ model: "local-claude",
+ endpoint: "http://localhost:8000/v1/messages",
+ structuredOutput: "tool input_schema",
+ extensionVersion: "0.3.2"
+ });
+ });
});
diff --git a/apps/extension/src/content/orchestrator.ts b/apps/extension/src/content/orchestrator.ts
index 195234b..0298f46 100644
--- a/apps/extension/src/content/orchestrator.ts
+++ b/apps/extension/src/content/orchestrator.ts
@@ -1,3 +1,4 @@
+import { isLocalTranslationProvider } from "../shared/localProviders";
import { normalizeText } from "../shared/text";
import type { ExtensionSettings, PageDebugState, TranslationProviderId, TranslationResult } from "../shared/types";
import type { BlockCandidate } from "./blockCandidates";
@@ -28,7 +29,8 @@ const PROVIDER_DISPLAY_NAMES: Record = {
openai: "OpenAI",
anthropic: "Anthropic Claude",
google: "Google Gemini",
- "openai-compatible": "OpenAI Compatible"
+ "openai-compatible": "OpenAI Compatible",
+ "anthropic-compatible": "Anthropic Compatible"
};
interface TranslationBatchResponse {
@@ -312,7 +314,7 @@ export function createOrchestrator(options: ContentOrchestratorOptions): Content
function configureTranslationQueue(provider: TranslationProviderId): void {
queue.configure(
- provider === "openai-compatible"
+ isLocalTranslationProvider(provider)
? { batchSize: LOCAL_BATCH_SIZE, concurrency: LOCAL_CONCURRENCY }
: { batchSize: BATCH_SIZE, concurrency: CONCURRENCY }
);
@@ -356,7 +358,7 @@ export function createOrchestrator(options: ContentOrchestratorOptions): Content
): string {
if (provider === "google") return "responseJsonSchema";
if (provider === "openai") return "json_schema";
- if (provider === "anthropic") return "tool input_schema";
+ if (provider === "anthropic" || provider === "anthropic-compatible") return "tool input_schema";
return settings?.openAICompatibleJsonMode ? "json_object" : "prompt only";
}
diff --git a/apps/extension/src/options/i18n/de.ts b/apps/extension/src/options/i18n/de.ts
index 5c792cf..38c8c09 100644
--- a/apps/extension/src/options/i18n/de.ts
+++ b/apps/extension/src/options/i18n/de.ts
@@ -2,7 +2,7 @@ import type { MessageDictionary } from "./types";
export const de = {
apiKey: "API key",
- apiKeyHint: "Fuege den rohen provider API key ein. Lokale OpenAI-compatible endpoints koennen leer bleiben.",
+ apiKeyHint: "Fuege den rohen provider API key ein. Lokale OpenAI-compatible und Anthropic-compatible endpoints koennen leer bleiben.",
cacheBehavior: "Cache-Verhalten",
cacheBehaviorHint:
"Nur Sitzung ist der privacy-first Standard. Persistenter Cache speichert Uebersetzungen in diesem Browserprofil, bis du den Cache leerst.",
@@ -28,6 +28,9 @@ export const de = {
fetchModelsBusy: "Wird abgerufen...",
floatingButton: "Schwebender Uebersetzen-Button",
floatingButtonHint: "Zeigt einen kleinen Seitenbutton, der erst nach einem Klick uebersetzt.",
+ localAnthropicEndpointHint:
+ "Verwende einen Anthropic Messages API endpoint, z. B. http://localhost:8000/v1/messages.",
+ localAnthropicPresets: "Lokaler Anthropic-compatible endpoint",
localJsonHint: "Deaktivieren, wenn ein lokaler runtime das OpenAI response_format Feld ablehnt.",
localJsonMode: "JSON mode anfordern",
localPresetDefault: "Lokalen runtime waehlen",
diff --git a/apps/extension/src/options/i18n/en.ts b/apps/extension/src/options/i18n/en.ts
index 3dc9640..77247d3 100644
--- a/apps/extension/src/options/i18n/en.ts
+++ b/apps/extension/src/options/i18n/en.ts
@@ -2,7 +2,7 @@ import type { MessageDictionary } from "./types";
export const en = {
apiKey: "API key",
- apiKeyHint: "Paste the raw provider API key. Local OpenAI-compatible endpoints can leave this empty.",
+ apiKeyHint: "Paste the raw provider API key. Local OpenAI-compatible and Anthropic-compatible endpoints can leave this empty.",
cacheBehavior: "Cache behavior",
cacheBehaviorHint:
"Session only is the privacy-first default. Persistent stores translated text in this browser profile until cleared.",
@@ -28,6 +28,9 @@ export const en = {
fetchModelsBusy: "Fetching...",
floatingButton: "Floating translate button",
floatingButtonHint: "Show a small page button that only translates after you click it.",
+ localAnthropicEndpointHint:
+ "Use an Anthropic Messages API endpoint such as http://localhost:8000/v1/messages.",
+ localAnthropicPresets: "Local Anthropic-compatible endpoint",
localJsonHint: "Disable this if a local runtime rejects the OpenAI response_format field.",
localJsonMode: "Request JSON mode",
localPresetDefault: "Select a local runtime",
diff --git a/apps/extension/src/options/i18n/es.ts b/apps/extension/src/options/i18n/es.ts
index f47b8c9..969a07c 100644
--- a/apps/extension/src/options/i18n/es.ts
+++ b/apps/extension/src/options/i18n/es.ts
@@ -2,7 +2,7 @@ import type { MessageDictionary } from "./types";
export const es = {
apiKey: "Clave de API",
- apiKeyHint: "Pega la API key sin procesar del provider. Los endpoints locales OpenAI-compatible pueden dejarlo vacío.",
+ apiKeyHint: "Pega la API key sin procesar del provider. Los endpoints locales OpenAI-compatible y Anthropic-compatible pueden dejarlo vacío.",
cacheBehavior: "Comportamiento de caché",
cacheBehaviorHint:
"Solo sesion es el valor predeterminado privacy-first. La cache persistente guarda traducciones en este perfil del navegador hasta que la borres.",
@@ -28,6 +28,9 @@ export const es = {
fetchModelsBusy: "Obteniendo...",
floatingButton: "Boton flotante de traduccion",
floatingButtonHint: "Muestra un boton pequeno en la pagina que solo traduce despues de hacer clic.",
+ localAnthropicEndpointHint:
+ "Usa un endpoint de Anthropic Messages API, por ejemplo http://localhost:8000/v1/messages.",
+ localAnthropicPresets: "Endpoint local Anthropic-compatible",
localJsonHint: "Desactívalo si un runtime local rechaza el campo OpenAI response_format.",
localJsonMode: "Solicitar modo JSON",
localPresetDefault: "Selecciona un runtime local",
diff --git a/apps/extension/src/options/i18n/fr.ts b/apps/extension/src/options/i18n/fr.ts
index e4c9ef0..b972666 100644
--- a/apps/extension/src/options/i18n/fr.ts
+++ b/apps/extension/src/options/i18n/fr.ts
@@ -2,7 +2,7 @@ import type { MessageDictionary } from "./types";
export const fr = {
apiKey: "Cle API",
- apiKeyHint: "Collez l'API key brute du provider. Les endpoints locaux OpenAI-compatible peuvent rester vides.",
+ apiKeyHint: "Collez l'API key brute du provider. Les endpoints locaux OpenAI-compatible et Anthropic-compatible peuvent rester vides.",
cacheBehavior: "Comportement du cache",
cacheBehaviorHint:
"Session uniquement est le choix privacy-first par defaut. Le cache persistant conserve les traductions dans ce profil de navigateur jusqu'a ce que vous le vidiez.",
@@ -28,6 +28,9 @@ export const fr = {
fetchModelsBusy: "Chargement...",
floatingButton: "Bouton flottant de traduction",
floatingButtonHint: "Affiche un petit bouton sur la page qui ne traduit qu'apres un clic.",
+ localAnthropicEndpointHint:
+ "Utilisez un endpoint Anthropic Messages API, par exemple http://localhost:8000/v1/messages.",
+ localAnthropicPresets: "Endpoint local Anthropic-compatible",
localJsonHint: "Desactivez ceci si un runtime local rejette le champ OpenAI response_format.",
localJsonMode: "Demander le mode JSON",
localPresetDefault: "Selectionner un runtime local",
diff --git a/apps/extension/src/options/i18n/ja.ts b/apps/extension/src/options/i18n/ja.ts
index 9ffcc2f..a557aad 100644
--- a/apps/extension/src/options/i18n/ja.ts
+++ b/apps/extension/src/options/i18n/ja.ts
@@ -2,7 +2,7 @@ import type { MessageDictionary } from "./types";
export const ja = {
apiKey: "API キー",
- apiKeyHint: "Provider の生 API key を貼り付けます。Local OpenAI-compatible endpoint では空欄にできます。",
+ apiKeyHint: "Provider の生 API key を貼り付けます。Local OpenAI-compatible と Anthropic-compatible endpoint では空欄にできます。",
cacheBehavior: "キャッシュ動作",
cacheBehaviorHint:
"「セッションのみ」がプライバシー優先の既定値です。「永続」は、消去するまで翻訳文をこのブラウザ profile に保存します。",
@@ -28,6 +28,9 @@ export const ja = {
fetchModelsBusy: "取得中...",
floatingButton: "フローティング翻訳ボタン",
floatingButtonHint: "ページ上に小さなボタンを表示し、クリックした後だけ翻訳します。",
+ localAnthropicEndpointHint:
+ "Anthropic Messages API endpoint(例: http://localhost:8000/v1/messages)を使用してください。",
+ localAnthropicPresets: "ローカル Anthropic-compatible endpoint",
localJsonHint: "Local runtime が OpenAI response_format を拒否する場合はオフにしてください。",
localJsonMode: "JSON mode を要求",
localPresetDefault: "Local runtime を選択",
diff --git a/apps/extension/src/options/i18n/ko.ts b/apps/extension/src/options/i18n/ko.ts
index 58b35ed..96f4d74 100644
--- a/apps/extension/src/options/i18n/ko.ts
+++ b/apps/extension/src/options/i18n/ko.ts
@@ -2,7 +2,7 @@ import type { MessageDictionary } from "./types";
export const ko = {
apiKey: "API 키",
- apiKeyHint: "Provider 원본 API key 를 붙여 넣으세요. Local OpenAI-compatible endpoint 는 비워 둘 수 있습니다.",
+ apiKeyHint: "Provider 원본 API key 를 붙여 넣으세요. Local OpenAI-compatible 및 Anthropic-compatible endpoint 는 비워 둘 수 있습니다.",
cacheBehavior: "캐시 방식",
cacheBehaviorHint:
"세션만이 개인정보 우선 기본값입니다. 영구 저장은 지울 때까지 번역문을 이 브라우저 profile 에 저장합니다.",
@@ -28,6 +28,9 @@ export const ko = {
fetchModelsBusy: "가져오는 중...",
floatingButton: "플로팅 번역 버튼",
floatingButtonHint: "웹페이지에 작은 버튼을 표시하고, 클릭한 뒤에만 번역합니다.",
+ localAnthropicEndpointHint:
+ "Anthropic Messages API endpoint(예: http://localhost:8000/v1/messages)를 사용하세요.",
+ localAnthropicPresets: "로컬 Anthropic-compatible endpoint",
localJsonHint: "Local runtime 이 OpenAI response_format 필드를 거부하면 이 옵션을 끄세요.",
localJsonMode: "JSON mode 요청",
localPresetDefault: "Local runtime 선택",
diff --git a/apps/extension/src/options/i18n/types.ts b/apps/extension/src/options/i18n/types.ts
index 8406d43..88b2ac1 100644
--- a/apps/extension/src/options/i18n/types.ts
+++ b/apps/extension/src/options/i18n/types.ts
@@ -25,6 +25,8 @@ export const MESSAGE_KEYS = [
"fetchModelsBusy",
"floatingButton",
"floatingButtonHint",
+ "localAnthropicEndpointHint",
+ "localAnthropicPresets",
"localJsonHint",
"localJsonMode",
"localPresetDefault",
diff --git a/apps/extension/src/options/i18n/zh-CN.ts b/apps/extension/src/options/i18n/zh-CN.ts
index 95aa986..5fd9861 100644
--- a/apps/extension/src/options/i18n/zh-CN.ts
+++ b/apps/extension/src/options/i18n/zh-CN.ts
@@ -2,7 +2,7 @@ import type { MessageDictionary } from "./types";
export const zhCN = {
apiKey: "API 密钥",
- apiKeyHint: "粘贴原始 provider API key。Local OpenAI-compatible endpoint 可以留空。",
+ apiKeyHint: "粘贴原始 provider API key。Local OpenAI-compatible 与 Anthropic-compatible endpoint 可以留空。",
cacheBehavior: "缓存行为",
cacheBehaviorHint:
"“仅本次浏览会话”是隐私优先的默认值。“持久保存”会把译文存在此浏览器 profile,直到你清除缓存。",
@@ -28,6 +28,8 @@ export const zhCN = {
fetchModelsBusy: "获取中...",
floatingButton: "浮动翻译按钮",
floatingButtonHint: "在网页上显示小型按钮,只有点击后才会开始翻译。",
+ localAnthropicEndpointHint: "请使用 Anthropic Messages API endpoint,例如 http://localhost:8000/v1/messages。",
+ localAnthropicPresets: "本地 Anthropic-compatible endpoint",
localJsonHint: "如果 local runtime 拒绝 OpenAI response_format 字段,请关闭此选项。",
localJsonMode: "要求 JSON mode",
localPresetDefault: "选择 local runtime",
diff --git a/apps/extension/src/options/i18n/zh-TW.ts b/apps/extension/src/options/i18n/zh-TW.ts
index 5f9d190..0672bb2 100644
--- a/apps/extension/src/options/i18n/zh-TW.ts
+++ b/apps/extension/src/options/i18n/zh-TW.ts
@@ -2,7 +2,7 @@ import type { MessageDictionary } from "./types";
export const zhTW = {
apiKey: "API 金鑰",
- apiKeyHint: "貼上原始 provider API key。Local OpenAI-compatible endpoint 可以留空。",
+ apiKeyHint: "貼上原始 provider API key。Local OpenAI-compatible 與 Anthropic-compatible endpoint 可以留空。",
cacheBehavior: "快取行為",
cacheBehaviorHint:
"「僅本次瀏覽階段」是隱私優先的預設值。「持久保存」會把譯文存在此瀏覽器 profile,直到你清除快取。",
@@ -28,6 +28,8 @@ export const zhTW = {
fetchModelsBusy: "取得中...",
floatingButton: "浮動翻譯按鈕",
floatingButtonHint: "在網頁上顯示小型按鈕,只有點擊後才會開始翻譯。",
+ localAnthropicEndpointHint: "請使用 Anthropic Messages API endpoint,例如 http://localhost:8000/v1/messages。",
+ localAnthropicPresets: "本機 Anthropic-compatible endpoint",
localJsonHint: "如果 local runtime 拒絕 OpenAI response_format 欄位,請關閉此選項。",
localJsonMode: "要求 JSON mode",
localPresetDefault: "選擇 local runtime",
diff --git a/apps/extension/src/options/optionsLayout.test.ts b/apps/extension/src/options/optionsLayout.test.ts
index 89d1217..1d664b7 100644
--- a/apps/extension/src/options/optionsLayout.test.ts
+++ b/apps/extension/src/options/optionsLayout.test.ts
@@ -15,13 +15,13 @@ describe("options layout", () => {
expect(fetchButton?.closest("label")).toBe(modelSelect?.closest("label"));
});
- it("scopes the provider endpoint to the OpenAI Compatible section", () => {
+ it("scopes the provider endpoint to local compatible provider sections", () => {
const document = createDocument(optionsHtml);
const endpointInput = document.querySelector('[name="providerEndpoint"]');
expect(endpointInput).not.toBeNull();
expect(endpointInput?.closest("[data-provider-section]")?.getAttribute("data-provider-section")).toBe(
- "openai-compatible"
+ "openai-compatible anthropic-compatible"
);
});
diff --git a/apps/extension/src/options/providerSettings.test.ts b/apps/extension/src/options/providerSettings.test.ts
index 8b88d2a..919e5f7 100644
--- a/apps/extension/src/options/providerSettings.test.ts
+++ b/apps/extension/src/options/providerSettings.test.ts
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { DEFAULT_SETTINGS } from "../shared/defaults";
+import { DEFAULT_SETTINGS, PROVIDER_DEFAULTS } from "../shared/defaults";
import { initializeProviderSettings } from "./providerSettings";
import { readForm } from "./settingsForm";
@@ -8,6 +8,7 @@ function setupOptionsDom(): void {
OpenAI
OpenAI Compatible
+ Anthropic Compatible
@@ -16,8 +17,11 @@ function setupOptionsDom(): void {
omlx
-
+
+
+
+
Fetch models
@@ -38,14 +42,39 @@ describe("provider settings", () => {
initializeProviderSettings({ locale: "en", readForm, setStatus: vi.fn() });
expect(document.querySelector("[data-provider-section='openai-compatible']")?.hidden).toBe(true);
+ expect(document.querySelector("[data-provider-section='anthropic-compatible']")?.hidden).toBe(true);
+ expect(
+ document.querySelector("[data-provider-section='openai-compatible anthropic-compatible']")?.hidden
+ ).toBe(true);
const providerInput = document.querySelector('[name="provider"]')!;
providerInput.value = "openai-compatible";
providerInput.dispatchEvent(new Event("change"));
expect(document.querySelector("[data-provider-section='openai-compatible']")?.hidden).toBe(false);
+ expect(document.querySelector("[data-provider-section='anthropic-compatible']")?.hidden).toBe(true);
+ expect(
+ document.querySelector("[data-provider-section='openai-compatible anthropic-compatible']")?.hidden
+ ).toBe(false);
+ expect(document.querySelector('[name="providerEndpoint"]')?.value).toBe(
+ PROVIDER_DEFAULTS["openai-compatible"].providerEndpoint
+ );
+ });
+
+ it("keeps provider endpoints scoped to Anthropic Compatible", () => {
+ initializeProviderSettings({ locale: "en", readForm, setStatus: vi.fn() });
+
+ const providerInput = document.querySelector('[name="provider"]')!;
+ providerInput.value = "anthropic-compatible";
+ providerInput.dispatchEvent(new Event("change"));
+
+ expect(document.querySelector("[data-provider-section='openai-compatible']")?.hidden).toBe(true);
+ expect(document.querySelector("[data-provider-section='anthropic-compatible']")?.hidden).toBe(false);
+ expect(
+ document.querySelector("[data-provider-section='openai-compatible anthropic-compatible']")?.hidden
+ ).toBe(false);
expect(document.querySelector('[name="providerEndpoint"]')?.value).toBe(
- "http://localhost:1234/v1/chat/completions"
+ PROVIDER_DEFAULTS["anthropic-compatible"].providerEndpoint
);
});
diff --git a/apps/extension/src/options/providerSettings.ts b/apps/extension/src/options/providerSettings.ts
index 0508e91..e71bcde 100644
--- a/apps/extension/src/options/providerSettings.ts
+++ b/apps/extension/src/options/providerSettings.ts
@@ -53,7 +53,8 @@ export function initializeProviderSettings({ locale, readForm, setStatus }: Prov
function updateProviderSections(provider: TranslationProviderId): void {
document.querySelectorAll("[data-provider-section]").forEach((section) => {
- section.hidden = section.dataset.providerSection !== provider;
+ const sections = (section.dataset.providerSection ?? "").split(/\s+/).filter(Boolean);
+ section.hidden = sections.length > 0 && !sections.includes(provider);
});
}
diff --git a/apps/extension/src/shared/defaults.test.ts b/apps/extension/src/shared/defaults.test.ts
index 97794e6..f06fb5b 100644
--- a/apps/extension/src/shared/defaults.test.ts
+++ b/apps/extension/src/shared/defaults.test.ts
@@ -3,7 +3,7 @@ import { DEFAULT_SETTINGS, PROVIDER_DEFAULTS } from "./defaults";
describe("provider defaults", () => {
it("defines defaults for every supported provider", () => {
- expect(Object.keys(PROVIDER_DEFAULTS).sort()).toEqual(["anthropic", "google", "openai", "openai-compatible"]);
+ expect(Object.keys(PROVIDER_DEFAULTS).sort()).toEqual(["anthropic", "anthropic-compatible", "google", "openai", "openai-compatible"]);
});
it("uses the OpenAI provider as the initial default", () => {
@@ -24,5 +24,8 @@ describe("provider defaults", () => {
expect(PROVIDER_DEFAULTS["openai-compatible"].providerEndpoint).toBe(
"http://localhost:1234/v1/chat/completions"
);
+ expect(PROVIDER_DEFAULTS["anthropic-compatible"].providerEndpoint).toBe(
+ "http://localhost:8000/v1/messages"
+ );
});
});
diff --git a/apps/extension/src/shared/defaults.ts b/apps/extension/src/shared/defaults.ts
index f710790..6aa82e8 100644
--- a/apps/extension/src/shared/defaults.ts
+++ b/apps/extension/src/shared/defaults.ts
@@ -19,6 +19,10 @@ export const PROVIDER_DEFAULTS: Record<
"openai-compatible": {
providerEndpoint: "http://localhost:1234/v1/chat/completions",
model: "local-model"
+ },
+ "anthropic-compatible": {
+ providerEndpoint: "http://localhost:8000/v1/messages",
+ model: "local-model"
}
};
diff --git a/apps/extension/src/shared/localProviders.ts b/apps/extension/src/shared/localProviders.ts
new file mode 100644
index 0000000..3b36525
--- /dev/null
+++ b/apps/extension/src/shared/localProviders.ts
@@ -0,0 +1,14 @@
+import type { TranslationProviderId } from "./types";
+
+export const LOCAL_TRANSLATION_PROVIDERS = [
+ "openai-compatible",
+ "anthropic-compatible"
+] as const satisfies readonly TranslationProviderId[];
+
+export type LocalTranslationProviderId = (typeof LOCAL_TRANSLATION_PROVIDERS)[number];
+
+export function isLocalTranslationProvider(
+ provider: TranslationProviderId
+): provider is LocalTranslationProviderId {
+ return (LOCAL_TRANSLATION_PROVIDERS as readonly string[]).includes(provider);
+}
diff --git a/apps/extension/src/shared/migrations/versions/v0.test.ts b/apps/extension/src/shared/migrations/versions/v0.test.ts
index 642068b..7cbf38c 100644
--- a/apps/extension/src/shared/migrations/versions/v0.test.ts
+++ b/apps/extension/src/shared/migrations/versions/v0.test.ts
@@ -37,6 +37,12 @@ describe("parseV0", () => {
expect(result.cacheMode).toBe("session");
});
+ it("rejects providers introduced after the v0 snapshot", () => {
+ const result = parseV0({ provider: "anthropic-compatible" });
+
+ expect(result.provider).toBeUndefined();
+ });
+
it("drops non-boolean values for boolean fields", () => {
const result = parseV0({
debugMode: "yes",
diff --git a/apps/extension/src/shared/migrations/versions/v0.ts b/apps/extension/src/shared/migrations/versions/v0.ts
index fb39cf1..08f5b40 100644
--- a/apps/extension/src/shared/migrations/versions/v0.ts
+++ b/apps/extension/src/shared/migrations/versions/v0.ts
@@ -11,10 +11,12 @@
* a rewrite of this file.
*/
-import type { CacheMode, DisplayStyle, TranslationProviderId } from "../../types";
+import type { CacheMode, DisplayStyle } from "../../types";
+
+type TranslationProviderIdV0 = "openai" | "anthropic" | "google" | "openai-compatible";
export interface ExtensionSettingsV0 {
- provider?: TranslationProviderId;
+ provider?: TranslationProviderIdV0;
providerEndpoint?: string;
apiKey?: string;
model?: string;
@@ -31,7 +33,7 @@ export interface ExtensionSettingsV0 {
showFloatingButton?: boolean;
}
-const PROVIDERS: readonly TranslationProviderId[] = ["openai", "anthropic", "google", "openai-compatible"];
+const PROVIDERS: readonly TranslationProviderIdV0[] = ["openai", "anthropic", "google", "openai-compatible"];
const DISPLAY_STYLES: readonly DisplayStyle[] = ["integrated", "highlighted"];
const CACHE_MODES: readonly CacheMode[] = ["session", "persistent", "disabled"];
diff --git a/apps/extension/src/shared/types.ts b/apps/extension/src/shared/types.ts
index 8e00054..5c4b2cd 100644
--- a/apps/extension/src/shared/types.ts
+++ b/apps/extension/src/shared/types.ts
@@ -4,7 +4,7 @@ export type DisplayStyle = ModernDisplayStyle | LegacyDisplayStyle;
export type CacheMode = "session" | "persistent" | "disabled";
-export type TranslationProviderId = "openai" | "anthropic" | "google" | "openai-compatible";
+export type TranslationProviderId = "openai" | "anthropic" | "google" | "openai-compatible" | "anthropic-compatible";
export const SETTINGS_VERSION = 1;