Mayo commited on
fix: keyring on Linux
Browse files- Cargo.lock +0 -46
- Dockerfile +10 -10
- docs/en-US/how-to/run-gui-headless-and-mcp.md +3 -21
- docs/en-US/reference/cli.md +0 -8
- docs/en-US/reference/settings.md +7 -3
- docs/ja-JP/how-to/run-gui-headless-and-mcp.md +6 -0
- docs/ja-JP/reference/settings.md +7 -3
- docs/pt-BR/how-to/run-gui-headless-and-mcp.md +3 -21
- docs/pt-BR/reference/cli.md +0 -8
- docs/pt-BR/reference/settings.md +7 -3
- docs/zh-CN/how-to/run-gui-headless-and-mcp.md +6 -0
- docs/zh-CN/reference/settings.md +7 -3
- koharu-app/src/config.rs +6 -7
- koharu-llm/Cargo.toml +0 -3
- koharu-llm/src/providers/credentials.rs +247 -0
- koharu-llm/src/providers/mod.rs +5 -65
- koharu-ml/Cargo.toml +0 -10
- koharu/src/app.rs +0 -4
- koharu/src/cli.rs +0 -2
Cargo.lock
CHANGED
|
@@ -1794,27 +1794,6 @@ version = "2.10.0"
|
|
| 1794 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1795 |
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
| 1796 |
|
| 1797 |
-
[[package]]
|
| 1798 |
-
name = "dbus"
|
| 1799 |
-
version = "0.9.10"
|
| 1800 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1801 |
-
checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
|
| 1802 |
-
dependencies = [
|
| 1803 |
-
"libc",
|
| 1804 |
-
"libdbus-sys",
|
| 1805 |
-
"windows-sys 0.59.0",
|
| 1806 |
-
]
|
| 1807 |
-
|
| 1808 |
-
[[package]]
|
| 1809 |
-
name = "dbus-secret-service"
|
| 1810 |
-
version = "4.1.0"
|
| 1811 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1812 |
-
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
|
| 1813 |
-
dependencies = [
|
| 1814 |
-
"dbus",
|
| 1815 |
-
"zeroize",
|
| 1816 |
-
]
|
| 1817 |
-
|
| 1818 |
[[package]]
|
| 1819 |
name = "debugid"
|
| 1820 |
version = "0.8.0"
|
|
@@ -4476,7 +4455,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
| 4476 |
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
| 4477 |
dependencies = [
|
| 4478 |
"byteorder",
|
| 4479 |
-
"dbus-secret-service",
|
| 4480 |
"log",
|
| 4481 |
"security-framework 2.11.1",
|
| 4482 |
"security-framework 3.7.0",
|
|
@@ -4678,7 +4656,6 @@ dependencies = [
|
|
| 4678 |
"futures",
|
| 4679 |
"image",
|
| 4680 |
"imageproc",
|
| 4681 |
-
"keyring",
|
| 4682 |
"koharu-runtime",
|
| 4683 |
"libloading 0.8.9",
|
| 4684 |
"minijinja",
|
|
@@ -4860,15 +4837,6 @@ version = "0.2.185"
|
|
| 4860 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 4861 |
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
| 4862 |
|
| 4863 |
-
[[package]]
|
| 4864 |
-
name = "libdbus-sys"
|
| 4865 |
-
version = "0.2.7"
|
| 4866 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 4867 |
-
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
|
| 4868 |
-
dependencies = [
|
| 4869 |
-
"pkg-config",
|
| 4870 |
-
]
|
| 4871 |
-
|
| 4872 |
[[package]]
|
| 4873 |
name = "libfuzzer-sys"
|
| 4874 |
version = "0.4.12"
|
|
@@ -11229,20 +11197,6 @@ name = "zeroize"
|
|
| 11229 |
version = "1.8.2"
|
| 11230 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 11231 |
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
| 11232 |
-
dependencies = [
|
| 11233 |
-
"zeroize_derive",
|
| 11234 |
-
]
|
| 11235 |
-
|
| 11236 |
-
[[package]]
|
| 11237 |
-
name = "zeroize_derive"
|
| 11238 |
-
version = "1.4.3"
|
| 11239 |
-
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 11240 |
-
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
| 11241 |
-
dependencies = [
|
| 11242 |
-
"proc-macro2",
|
| 11243 |
-
"quote",
|
| 11244 |
-
"syn 2.0.117",
|
| 11245 |
-
]
|
| 11246 |
|
| 11247 |
[[package]]
|
| 11248 |
name = "zerotrie"
|
|
|
|
| 1794 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1795 |
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
| 1796 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1797 |
[[package]]
|
| 1798 |
name = "debugid"
|
| 1799 |
version = "0.8.0"
|
|
|
|
| 4455 |
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
| 4456 |
dependencies = [
|
| 4457 |
"byteorder",
|
|
|
|
| 4458 |
"log",
|
| 4459 |
"security-framework 2.11.1",
|
| 4460 |
"security-framework 3.7.0",
|
|
|
|
| 4656 |
"futures",
|
| 4657 |
"image",
|
| 4658 |
"imageproc",
|
|
|
|
| 4659 |
"koharu-runtime",
|
| 4660 |
"libloading 0.8.9",
|
| 4661 |
"minijinja",
|
|
|
|
| 4837 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 4838 |
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
| 4839 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4840 |
[[package]]
|
| 4841 |
name = "libfuzzer-sys"
|
| 4842 |
version = "0.4.12"
|
|
|
|
| 11197 |
version = "1.8.2"
|
| 11198 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 11199 |
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11200 |
|
| 11201 |
[[package]]
|
| 11202 |
name = "zerotrie"
|
Dockerfile
CHANGED
|
@@ -6,15 +6,15 @@ ARG DEBIAN_FRONTEND=noninteractive
|
|
| 6 |
|
| 7 |
RUN apt-get update \
|
| 8 |
&& apt-get install -y --no-install-recommends \
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
&& curl -fL "https://github.com/mayocream/koharu/releases/latest/download/koharu_linux_x64" -o /usr/local/bin/koharu \
|
| 19 |
&& chmod 0755 /usr/local/bin/koharu \
|
| 20 |
&& apt-get purge -y --auto-remove curl \
|
|
@@ -29,4 +29,4 @@ WORKDIR /home/koharu
|
|
| 29 |
VOLUME ["/home/koharu/.local/share/Koharu"]
|
| 30 |
EXPOSE 4000
|
| 31 |
|
| 32 |
-
CMD ["/usr/local/bin/koharu", "--headless", "--
|
|
|
|
| 6 |
|
| 7 |
RUN apt-get update \
|
| 8 |
&& apt-get install -y --no-install-recommends \
|
| 9 |
+
ca-certificates \
|
| 10 |
+
curl \
|
| 11 |
+
fonts-noto-cjk \
|
| 12 |
+
libayatana-appindicator3-1 \
|
| 13 |
+
libgomp1 \
|
| 14 |
+
librsvg2-2 \
|
| 15 |
+
libssl3 \
|
| 16 |
+
libwebkit2gtk-4.1-0 \
|
| 17 |
+
libxdo3 \
|
| 18 |
&& curl -fL "https://github.com/mayocream/koharu/releases/latest/download/koharu_linux_x64" -o /usr/local/bin/koharu \
|
| 19 |
&& chmod 0755 /usr/local/bin/koharu \
|
| 20 |
&& apt-get purge -y --auto-remove curl \
|
|
|
|
| 29 |
VOLUME ["/home/koharu/.local/share/Koharu"]
|
| 30 |
EXPOSE 4000
|
| 31 |
|
| 32 |
+
CMD ["/usr/local/bin/koharu", "--headless", "--host", "0.0.0.0", "--port", "4000"]
|
docs/en-US/how-to/run-gui-headless-and-mcp.md
CHANGED
|
@@ -142,26 +142,8 @@ koharu.exe --debug
|
|
| 142 |
|
| 143 |
On Windows, debug and headless runs also influence how Koharu attaches to or creates a console window.
|
| 144 |
|
| 145 |
-
##
|
| 146 |
|
| 147 |
-
By default, Koharu stores API keys
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
| Provider | Environment variable |
|
| 152 |
-
| --- | --- |
|
| 153 |
-
| OpenAI | `KOHARU_OPENAI_API_KEY` |
|
| 154 |
-
| Gemini | `KOHARU_GEMINI_API_KEY` |
|
| 155 |
-
| Claude | `KOHARU_CLAUDE_API_KEY` |
|
| 156 |
-
| DeepSeek | `KOHARU_DEEPSEEK_API_KEY` |
|
| 157 |
-
| OpenAI-compatible | `KOHARU_OPENAI_COMPATIBLE_API_KEY` |
|
| 158 |
-
|
| 159 |
-
Hyphens in provider IDs are converted to underscores in the variable name.
|
| 160 |
-
|
| 161 |
-
Example for a headless container run:
|
| 162 |
-
|
| 163 |
-
```bash
|
| 164 |
-
KOHARU_OPENAI_API_KEY=sk-... koharu --port 9999 --headless --no-keyring
|
| 165 |
-
```
|
| 166 |
-
|
| 167 |
-
When `--no-keyring` is active, calls to save an API key through the UI or HTTP API are ignored.
|
|
|
|
| 142 |
|
| 143 |
On Windows, debug and headless runs also influence how Koharu attaches to or creates a console window.
|
| 144 |
|
| 145 |
+
## Credential storage
|
| 146 |
|
| 147 |
+
By default, Koharu stores API keys outside `config.toml`. macOS and Windows use the system keyring. Linux uses Koharu's local filesystem credential store under the app data directory with owner-only file permissions; this Linux store relies on filesystem permissions rather than OS-level encryption.
|
| 148 |
|
| 149 |
+
Headless and container runs use the same credential storage behavior as the desktop app. For containers, keep the app data directory on a persistent volume if you want saved API keys to survive container replacement.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/en-US/reference/cli.md
CHANGED
|
@@ -31,7 +31,6 @@ koharu.exe [OPTIONS]
|
|
| 31 |
| `--cpu` | Force CPU mode even when a GPU is available |
|
| 32 |
| `-p`, `--port <PORT>` | Bind the local HTTP server to a specific `127.0.0.1` port instead of a random one |
|
| 33 |
| `--headless` | Run without starting the desktop GUI |
|
| 34 |
-
| `--no-keyring` | Run without keyring and use environment variables instead |
|
| 35 |
| `--debug` | Enable debug-oriented console output |
|
| 36 |
|
| 37 |
## Behavior notes
|
|
@@ -42,7 +41,6 @@ Some flags affect more than startup appearance:
|
|
| 42 |
- with `--headless`, Koharu skips the Tauri window but still serves the Web UI and API
|
| 43 |
- with `--download`, Koharu exits after dependency prefetch and does not stay running
|
| 44 |
- with `--cpu`, both the vision stack and local LLM path avoid GPU acceleration
|
| 45 |
-
- with `--no-keyring`, Koharu skips all keyring operations, API keys must be set via environment variables
|
| 46 |
|
| 47 |
When a fixed port is set, the main local endpoints are:
|
| 48 |
|
|
@@ -87,9 +85,3 @@ Start with explicit debug logging:
|
|
| 87 |
```bash
|
| 88 |
koharu --debug
|
| 89 |
```
|
| 90 |
-
|
| 91 |
-
Use without keyring:
|
| 92 |
-
|
| 93 |
-
```bash
|
| 94 |
-
KOHARU_OPENAI_API_KEY=[key] koharu --no-keyring
|
| 95 |
-
```
|
|
|
|
| 31 |
| `--cpu` | Force CPU mode even when a GPU is available |
|
| 32 |
| `-p`, `--port <PORT>` | Bind the local HTTP server to a specific `127.0.0.1` port instead of a random one |
|
| 33 |
| `--headless` | Run without starting the desktop GUI |
|
|
|
|
| 34 |
| `--debug` | Enable debug-oriented console output |
|
| 35 |
|
| 36 |
## Behavior notes
|
|
|
|
| 41 |
- with `--headless`, Koharu skips the Tauri window but still serves the Web UI and API
|
| 42 |
- with `--download`, Koharu exits after dependency prefetch and does not stay running
|
| 43 |
- with `--cpu`, both the vision stack and local LLM path avoid GPU acceleration
|
|
|
|
| 44 |
|
| 45 |
When a fixed port is set, the main local endpoints are:
|
| 46 |
|
|
|
|
| 85 |
```bash
|
| 86 |
koharu --debug
|
| 87 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/en-US/reference/settings.md
CHANGED
|
@@ -51,14 +51,18 @@ The `API Keys` tab currently covers these built-in providers:
|
|
| 51 |
|
| 52 |
Current behavior:
|
| 53 |
|
| 54 |
-
- provider API keys are
|
|
|
|
|
|
|
| 55 |
- provider base URLs are stored in the app config
|
| 56 |
- `OpenAI Compatible` requires a custom `Base URL`
|
| 57 |
- the app discovers models dynamically for `OpenAI Compatible` by querying the configured endpoint
|
| 58 |
-
- clearing a key removes it from
|
| 59 |
|
| 60 |
The API response intentionally redacts saved keys rather than returning the raw secret.
|
| 61 |
|
|
|
|
|
|
|
| 62 |
## Runtime
|
| 63 |
|
| 64 |
The `Runtime` tab groups restart-required settings that affect the shared local runtime:
|
|
@@ -93,7 +97,7 @@ In packaged app mode, the version check compares the local app version against t
|
|
| 93 |
The current settings behavior is split across storage layers:
|
| 94 |
|
| 95 |
- `config.toml` stores shared app config such as `data`, `http`, `pipeline`, and provider `baseUrl`
|
| 96 |
-
- provider API keys are stored through the
|
| 97 |
- theme, language, and rendering-font preferences are stored in the frontend preferences layer
|
| 98 |
|
| 99 |
That means clearing frontend preferences is not the same as clearing saved provider API keys or shared runtime config.
|
|
|
|
| 51 |
|
| 52 |
Current behavior:
|
| 53 |
|
| 54 |
+
- provider API keys are not written to `config.toml`
|
| 55 |
+
- on macOS and Windows, provider API keys are stored through the system keyring
|
| 56 |
+
- on Linux, provider API keys are stored in Koharu's local filesystem credential store under the app data directory with owner-only file permissions
|
| 57 |
- provider base URLs are stored in the app config
|
| 58 |
- `OpenAI Compatible` requires a custom `Base URL`
|
| 59 |
- the app discovers models dynamically for `OpenAI Compatible` by querying the configured endpoint
|
| 60 |
+
- clearing a key removes it from credential storage
|
| 61 |
|
| 62 |
The API response intentionally redacts saved keys rather than returning the raw secret.
|
| 63 |
|
| 64 |
+
The Linux filesystem credential store relies on local filesystem permissions rather than OS-level encryption.
|
| 65 |
+
|
| 66 |
## Runtime
|
| 67 |
|
| 68 |
The `Runtime` tab groups restart-required settings that affect the shared local runtime:
|
|
|
|
| 97 |
The current settings behavior is split across storage layers:
|
| 98 |
|
| 99 |
- `config.toml` stores shared app config such as `data`, `http`, `pipeline`, and provider `baseUrl`
|
| 100 |
+
- provider API keys are stored separately from `config.toml` through the platform credential store described above
|
| 101 |
- theme, language, and rendering-font preferences are stored in the frontend preferences layer
|
| 102 |
|
| 103 |
That means clearing frontend preferences is not the same as clearing saved provider API keys or shared runtime config.
|
docs/ja-JP/how-to/run-gui-headless-and-mcp.md
CHANGED
|
@@ -141,3 +141,9 @@ koharu.exe --debug
|
|
| 141 |
```
|
| 142 |
|
| 143 |
Windows では、debug 実行と headless 実行の組み合わせにより、Koharu がコンソールウィンドウにアタッチするか、新しく作るかが変わります。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
```
|
| 142 |
|
| 143 |
Windows では、debug 実行と headless 実行の組み合わせにより、Koharu がコンソールウィンドウにアタッチするか、新しく作るかが変わります。
|
| 144 |
+
|
| 145 |
+
## 認証情報ストレージ
|
| 146 |
+
|
| 147 |
+
Koharu は API キーを `config.toml` の外に保存します。macOS と Windows ではシステム keyring を使います。Linux ではアプリデータディレクトリ配下の Koharu ローカルファイルシステム認証情報ストアを使い、所有ユーザーのみが読める権限を設定します。この Linux ストアは OS レベルの暗号化ではなく、ファイルシステム権限に依存します。
|
| 148 |
+
|
| 149 |
+
Headless 実行やコンテナ実行でも、デスクトップアプリと同じ認証情報ストレージを使います。コンテナを置き換えた後も保存済み API キーを維持したい場合は、アプリデータディレクトリを永続 volume に置いてください。
|
docs/ja-JP/reference/settings.md
CHANGED
|
@@ -51,14 +51,18 @@ title: 設定リファレンス
|
|
| 51 |
|
| 52 |
現在の挙動:
|
| 53 |
|
| 54 |
-
- provider の API キーは `config.toml`
|
|
|
|
|
|
|
| 55 |
- provider の `Base URL` は共有アプリ設定に保存されます
|
| 56 |
- `OpenAI Compatible` ではカスタム `Base URL` が必須です
|
| 57 |
- `OpenAI Compatible` のモデル一覧は設定済みエンドポイントへの問い合わせで動的取得されます
|
| 58 |
-
- キーをクリアすると
|
| 59 |
|
| 60 |
API レスポンスでは保存済みキーは生値ではなく、マスク済みの値として返されます。
|
| 61 |
|
|
|
|
|
|
|
| 62 |
## Runtime
|
| 63 |
|
| 64 |
`Runtime` タブでは、共有ローカルランタイムに影響する再起動必須の設定をまとめています。
|
|
@@ -93,7 +97,7 @@ API レスポンスでは保存済みキーは生値ではなく、マスク済
|
|
| 93 |
現在の設定保存は複数の層に分かれています。
|
| 94 |
|
| 95 |
- `config.toml` には `data`、`http`、`pipeline`、provider の `baseUrl` など共有設定が保存されます
|
| 96 |
-
- provider API キーは
|
| 97 |
- テーマ、言語、描画フォントはフロントエンドの preferences 層に保存されます
|
| 98 |
|
| 99 |
つまり、フロントエンドの preferences を消しても、保存済みの provider API キーや共有ランタイム設定までは消えません。
|
|
|
|
| 51 |
|
| 52 |
現在の挙動:
|
| 53 |
|
| 54 |
+
- provider の API キーは `config.toml` には書き込まれません
|
| 55 |
+
- macOS と Windows では、provider の API キーはシステム keyring に保存されます
|
| 56 |
+
- Linux では、provider の API キーはアプリデータディレクトリ配下の Koharu ローカルファイルシステム認証情報ストアに保存され、所有ユーザーのみが読める権限が設定されます
|
| 57 |
- provider の `Base URL` は共有アプリ設定に保存されます
|
| 58 |
- `OpenAI Compatible` ではカスタム `Base URL` が必須です
|
| 59 |
- `OpenAI Compatible` のモデル一覧は設定済みエンドポイントへの問い合わせで動的取得されます
|
| 60 |
+
- キーをクリアすると認証情報ストレージから削除されます
|
| 61 |
|
| 62 |
API レスポンスでは保存済みキーは生値ではなく、マスク済みの値として返されます。
|
| 63 |
|
| 64 |
+
Linux のファイルシステム認証情報ストアは、OS レベルの暗号化ではなくローカルファイルシステム権限に依存します。
|
| 65 |
+
|
| 66 |
## Runtime
|
| 67 |
|
| 68 |
`Runtime` タブでは、共有ローカルランタイムに影響する再起動必須の設定をまとめています。
|
|
|
|
| 97 |
現在の設定保存は複数の層に分かれています。
|
| 98 |
|
| 99 |
- `config.toml` には `data`、`http`、`pipeline`、provider の `baseUrl` など共有設定が保存されます
|
| 100 |
+
- provider API キーは、上記のプラットフォーム認証情報ストレージを通じて `config.toml` とは別に保存されます
|
| 101 |
- テーマ、言語、描画フォントはフロントエンドの preferences 層に保存されます
|
| 102 |
|
| 103 |
つまり、フロントエンドの preferences を消しても、保存済みの provider API キーや共有ランタイム設定までは消えません。
|
docs/pt-BR/how-to/run-gui-headless-and-mcp.md
CHANGED
|
@@ -142,26 +142,8 @@ koharu.exe --debug
|
|
| 142 |
|
| 143 |
No Windows, execuções de debug e headless também influenciam como o Koharu anexa ou cria uma janela de console.
|
| 144 |
|
| 145 |
-
##
|
| 146 |
|
| 147 |
-
Por padrão, o Koharu armazena API keys
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
| Provider | Variável de ambiente |
|
| 152 |
-
| --- | --- |
|
| 153 |
-
| OpenAI | `KOHARU_OPENAI_API_KEY` |
|
| 154 |
-
| Gemini | `KOHARU_GEMINI_API_KEY` |
|
| 155 |
-
| Claude | `KOHARU_CLAUDE_API_KEY` |
|
| 156 |
-
| DeepSeek | `KOHARU_DEEPSEEK_API_KEY` |
|
| 157 |
-
| OpenAI-compatible | `KOHARU_OPENAI_COMPATIBLE_API_KEY` |
|
| 158 |
-
|
| 159 |
-
Hífens nos IDs de provider são convertidos em underscores no nome da variável.
|
| 160 |
-
|
| 161 |
-
Exemplo para uma execução headless em container:
|
| 162 |
-
|
| 163 |
-
```bash
|
| 164 |
-
KOHARU_OPENAI_API_KEY=sk-... koharu --port 9999 --headless --no-keyring
|
| 165 |
-
```
|
| 166 |
-
|
| 167 |
-
Quando `--no-keyring` está ativo, chamadas para salvar uma API key pela UI ou pela HTTP API são ignoradas.
|
|
|
|
| 142 |
|
| 143 |
No Windows, execuções de debug e headless também influenciam como o Koharu anexa ou cria uma janela de console.
|
| 144 |
|
| 145 |
+
## Armazenamento de credenciais
|
| 146 |
|
| 147 |
+
Por padrão, o Koharu armazena API keys fora de `config.toml`. macOS e Windows usam o keyring do sistema. Linux usa o armazenamento local de credenciais do Koharu no diretório de dados do app com permissões somente para o usuário dono; esse armazenamento no Linux depende das permissões do filesystem em vez de criptografia em nível de sistema operacional.
|
| 148 |
|
| 149 |
+
Execuções headless e em container usam o mesmo comportamento de armazenamento de credenciais do app desktop. Em containers, mantenha o diretório de dados do app em um volume persistente se quiser que as API keys salvas sobrevivam à substituição do container.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/pt-BR/reference/cli.md
CHANGED
|
@@ -31,7 +31,6 @@ koharu.exe [OPTIONS]
|
|
| 31 |
| `--cpu` | Força o modo CPU mesmo quando uma GPU está disponível |
|
| 32 |
| `-p`, `--port <PORT>` | Vincula o servidor HTTP local a uma porta `127.0.0.1` específica em vez de uma aleatória |
|
| 33 |
| `--headless` | Executa sem iniciar a GUI desktop |
|
| 34 |
-
| `--no-keyring` | Executa sem keyring e usa variáveis de ambiente no lugar |
|
| 35 |
| `--debug` | Habilita saída de console orientada a debug |
|
| 36 |
|
| 37 |
## Notas de comportamento
|
|
@@ -42,7 +41,6 @@ Algumas flags afetam mais do que apenas a aparência inicial:
|
|
| 42 |
- com `--headless`, o Koharu pula a janela do Tauri mas ainda serve a Web UI e a API
|
| 43 |
- com `--download`, o Koharu encerra após o prefetch de dependências e não permanece em execução
|
| 44 |
- com `--cpu`, tanto a stack de visão quanto o caminho do LLM local evitam aceleração por GPU
|
| 45 |
-
- com `--no-keyring`, o Koharu pula todas as operações de keyring; as chaves de API devem ser definidas por variáveis de ambiente
|
| 46 |
|
| 47 |
Quando uma porta fixa está definida, os principais endpoints locais são:
|
| 48 |
|
|
@@ -87,9 +85,3 @@ Iniciar com logging explícito de debug:
|
|
| 87 |
```bash
|
| 88 |
koharu --debug
|
| 89 |
```
|
| 90 |
-
|
| 91 |
-
Uso sem keyring:
|
| 92 |
-
|
| 93 |
-
```bash
|
| 94 |
-
KOHARU_OPENAI_API_KEY=[key] koharu --no-keyring
|
| 95 |
-
```
|
|
|
|
| 31 |
| `--cpu` | Força o modo CPU mesmo quando uma GPU está disponível |
|
| 32 |
| `-p`, `--port <PORT>` | Vincula o servidor HTTP local a uma porta `127.0.0.1` específica em vez de uma aleatória |
|
| 33 |
| `--headless` | Executa sem iniciar a GUI desktop |
|
|
|
|
| 34 |
| `--debug` | Habilita saída de console orientada a debug |
|
| 35 |
|
| 36 |
## Notas de comportamento
|
|
|
|
| 41 |
- com `--headless`, o Koharu pula a janela do Tauri mas ainda serve a Web UI e a API
|
| 42 |
- com `--download`, o Koharu encerra após o prefetch de dependências e não permanece em execução
|
| 43 |
- com `--cpu`, tanto a stack de visão quanto o caminho do LLM local evitam aceleração por GPU
|
|
|
|
| 44 |
|
| 45 |
Quando uma porta fixa está definida, os principais endpoints locais são:
|
| 46 |
|
|
|
|
| 85 |
```bash
|
| 86 |
koharu --debug
|
| 87 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/pt-BR/reference/settings.md
CHANGED
|
@@ -51,14 +51,18 @@ A aba `API Keys` cobre atualmente estes provedores embutidos:
|
|
| 51 |
|
| 52 |
Comportamento atual:
|
| 53 |
|
| 54 |
-
- as chaves de API dos provedores são
|
|
|
|
|
|
|
| 55 |
- as base URLs dos provedores são armazenadas na config do app
|
| 56 |
- `OpenAI Compatible` requer uma `Base URL` customizada
|
| 57 |
- o app descobre modelos dinamicamente para `OpenAI Compatible` consultando o endpoint configurado
|
| 58 |
-
- limpar uma chave a remove do
|
| 59 |
|
| 60 |
O response da API intencionalmente redacta as chaves salvas em vez de retornar o segredo bruto.
|
| 61 |
|
|
|
|
|
|
|
| 62 |
## Runtime
|
| 63 |
|
| 64 |
A aba `Runtime` agrupa configurações que exigem reinicialização e afetam o runtime local compartilhado:
|
|
@@ -93,7 +97,7 @@ No modo de app empacotado, a verificação de versão compara a versão local do
|
|
| 93 |
O comportamento atual das configurações é dividido em camadas de armazenamento:
|
| 94 |
|
| 95 |
- `config.toml` armazena a config compartilhada do app, como `data`, `http`, `pipeline` e `baseUrl` dos provedores
|
| 96 |
-
- as chaves de API dos provedores são armazenadas
|
| 97 |
- as preferências de tema, idioma e fonte de renderização são armazenadas na camada de preferências do frontend
|
| 98 |
|
| 99 |
Ou seja, limpar as preferências do frontend não é o mesmo que limpar as chaves de API salvas dos provedores ou a config compartilhada de runtime.
|
|
|
|
| 51 |
|
| 52 |
Comportamento atual:
|
| 53 |
|
| 54 |
+
- as chaves de API dos provedores não são escritas em `config.toml`
|
| 55 |
+
- no macOS e no Windows, as chaves de API dos provedores são armazenadas pelo keyring do sistema
|
| 56 |
+
- no Linux, as chaves de API dos provedores são armazenadas no armazenamento local de credenciais do Koharu sob o diretório de dados do app com permissões somente para o usuário dono
|
| 57 |
- as base URLs dos provedores são armazenadas na config do app
|
| 58 |
- `OpenAI Compatible` requer uma `Base URL` customizada
|
| 59 |
- o app descobre modelos dinamicamente para `OpenAI Compatible` consultando o endpoint configurado
|
| 60 |
+
- limpar uma chave a remove do armazenamento de credenciais
|
| 61 |
|
| 62 |
O response da API intencionalmente redacta as chaves salvas em vez de retornar o segredo bruto.
|
| 63 |
|
| 64 |
+
O armazenamento local de credenciais no Linux depende das permissões do filesystem em vez de criptografia em nível de sistema operacional.
|
| 65 |
+
|
| 66 |
## Runtime
|
| 67 |
|
| 68 |
A aba `Runtime` agrupa configurações que exigem reinicialização e afetam o runtime local compartilhado:
|
|
|
|
| 97 |
O comportamento atual das configurações é dividido em camadas de armazenamento:
|
| 98 |
|
| 99 |
- `config.toml` armazena a config compartilhada do app, como `data`, `http`, `pipeline` e `baseUrl` dos provedores
|
| 100 |
+
- as chaves de API dos provedores são armazenadas separadamente de `config.toml` pelo armazenamento de credenciais da plataforma descrito acima
|
| 101 |
- as preferências de tema, idioma e fonte de renderização são armazenadas na camada de preferências do frontend
|
| 102 |
|
| 103 |
Ou seja, limpar as preferências do frontend não é o mesmo que limpar as chaves de API salvas dos provedores ou a config compartilhada de runtime.
|
docs/zh-CN/how-to/run-gui-headless-and-mcp.md
CHANGED
|
@@ -141,3 +141,9 @@ koharu.exe --debug
|
|
| 141 |
```
|
| 142 |
|
| 143 |
在 Windows 上,debug 与 headless 运行方式还会影响 Koharu 如何附加到现有控制台,或创建新的控制台窗口。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
```
|
| 142 |
|
| 143 |
在 Windows 上,debug 与 headless 运行方式还会影响 Koharu 如何附加到现有控制台,或创建新的控制台窗口。
|
| 144 |
+
|
| 145 |
+
## 凭据存储
|
| 146 |
+
|
| 147 |
+
Koharu 会把 API key 存储在 `config.toml` 之外。macOS 和 Windows 使用系统 keyring。Linux 使用应用数据目录下的 Koharu 本地文件系统凭据存储,并设置仅所有者可访问的文件权限;这个 Linux 存储依赖文件系统权限,而不是操作系统级加密。
|
| 148 |
+
|
| 149 |
+
Headless 和容器运行使用与桌面应用相同的凭据存储行为。如果你希望保存的 API key 在容器替换后继续存在,请把应用数据目录放在持久化 volume 上。
|
docs/zh-CN/reference/settings.md
CHANGED
|
@@ -51,14 +51,18 @@ title: 设置参考
|
|
| 51 |
|
| 52 |
当前行为:
|
| 53 |
|
| 54 |
-
- 提供方 API key
|
|
|
|
|
|
|
| 55 |
- 提供方的 `Base URL` 保存在共享应用配置中
|
| 56 |
- `OpenAI Compatible` 需要自定义 `Base URL`
|
| 57 |
- `OpenAI Compatible` 的模型列表会通过查询已配置端点动态发现
|
| 58 |
-
- 清除密钥会把它从
|
| 59 |
|
| 60 |
API 响应不会返回原始密钥,而是返回已遮罩的值。
|
| 61 |
|
|
|
|
|
|
|
| 62 |
## Runtime
|
| 63 |
|
| 64 |
`Runtime` 标签页集中放置会影响共享本地运行时、且需要重启后生效的设置:
|
|
@@ -93,7 +97,7 @@ API 响应不会返回原始密钥,而是返回已遮罩的值。
|
|
| 93 |
当前设置数据分布在多个存储层中:
|
| 94 |
|
| 95 |
- `config.toml` 保存 `data`、`http`、`pipeline` 以及提供方 `baseUrl` 等共享配置
|
| 96 |
-
- 提供方 API key 存储
|
| 97 |
- 主题、语言和渲染字体存储在前端 preferences 层中
|
| 98 |
|
| 99 |
因此,清除前端 preferences 并不等于清除已保存的提供方 API key 或共享运行时配置。
|
|
|
|
| 51 |
|
| 52 |
当前行为:
|
| 53 |
|
| 54 |
+
- 提供方 API key 不会写入 `config.toml`
|
| 55 |
+
- 在 macOS 和 Windows 上,提供方 API key 存储在系统 keyring 中
|
| 56 |
+
- 在 Linux 上,提供方 API key 存储在应用数据目录下的 Koharu 本地文件系统凭据存储中,并使用仅所有者可访问的文件权限
|
| 57 |
- 提供方的 `Base URL` 保存在共享应用配置中
|
| 58 |
- `OpenAI Compatible` 需要自定义 `Base URL`
|
| 59 |
- `OpenAI Compatible` 的模型列表会通过查询已配置端点动态发现
|
| 60 |
+
- 清除密钥会把它从凭据存储中删除
|
| 61 |
|
| 62 |
API 响应不会返回原始密钥,而是返回已遮罩的值。
|
| 63 |
|
| 64 |
+
Linux 文件系统凭据存储依赖本地文件系统权限,而不是操作系统级加密。
|
| 65 |
+
|
| 66 |
## Runtime
|
| 67 |
|
| 68 |
`Runtime` 标签页集中放置会影响共享本地运行时、且需要重启后生效的设置:
|
|
|
|
| 97 |
当前设置数据分布在多个存储层中:
|
| 98 |
|
| 99 |
- `config.toml` 保存 `data`、`http`、`pipeline` 以及提供方 `baseUrl` 等共享配置
|
| 100 |
+
- 提供方 API key 通过上文所述的平台凭据存储与 `config.toml` 分开保存
|
| 101 |
- 主题、语言和渲染字体存储在前端 preferences 层中
|
| 102 |
|
| 103 |
因此,清除前端 preferences 并不等于清除已保存的提供方 API key 或共享运行时配置。
|
koharu-app/src/config.rs
CHANGED
|
@@ -113,9 +113,8 @@ pub struct ProviderConfig {
|
|
| 113 |
pub id: String,
|
| 114 |
#[serde(skip_serializing_if = "Option::is_none")]
|
| 115 |
pub base_url: Option<String>,
|
| 116 |
-
/// Populated from
|
| 117 |
/// Serializes as `"[REDACTED]"` in API responses.
|
| 118 |
-
/// Populated from keyring on `load()`. Serializes as `"[REDACTED]"`.
|
| 119 |
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 120 |
#[schema(value_type = Option<String>)]
|
| 121 |
pub api_key: Option<RedactedSecret>,
|
|
@@ -163,7 +162,7 @@ pub fn load() -> Result<AppConfig> {
|
|
| 163 |
save(&config)?;
|
| 164 |
}
|
| 165 |
|
| 166 |
-
// Populate api_key from
|
| 167 |
for provider in &mut config.providers {
|
| 168 |
if let Ok(Some(key)) = get_saved_api_key(&provider.id)
|
| 169 |
&& !key.trim().is_empty()
|
|
@@ -348,12 +347,12 @@ fn validate_engine_name(
|
|
| 348 |
}
|
| 349 |
|
| 350 |
// ---------------------------------------------------------------------------
|
| 351 |
-
// Secret
|
| 352 |
// ---------------------------------------------------------------------------
|
| 353 |
|
| 354 |
-
/// Sync api_key fields to
|
| 355 |
-
/// - `Some(RedactedSecret)` with value != "[REDACTED]" → save to
|
| 356 |
-
/// - `None` → clear from
|
| 357 |
/// - `Some(RedactedSecret)` with value == "[REDACTED]" → unchanged
|
| 358 |
pub fn sync_secrets(config: &AppConfig) -> Result<()> {
|
| 359 |
for provider in &config.providers {
|
|
|
|
| 113 |
pub id: String,
|
| 114 |
#[serde(skip_serializing_if = "Option::is_none")]
|
| 115 |
pub base_url: Option<String>,
|
| 116 |
+
/// Populated from credential storage on `load()`, never written to config.toml.
|
| 117 |
/// Serializes as `"[REDACTED]"` in API responses.
|
|
|
|
| 118 |
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 119 |
#[schema(value_type = Option<String>)]
|
| 120 |
pub api_key: Option<RedactedSecret>,
|
|
|
|
| 162 |
save(&config)?;
|
| 163 |
}
|
| 164 |
|
| 165 |
+
// Populate api_key from credential storage for every known provider.
|
| 166 |
for provider in &mut config.providers {
|
| 167 |
if let Ok(Some(key)) = get_saved_api_key(&provider.id)
|
| 168 |
&& !key.trim().is_empty()
|
|
|
|
| 347 |
}
|
| 348 |
|
| 349 |
// ---------------------------------------------------------------------------
|
| 350 |
+
// Secret handling
|
| 351 |
// ---------------------------------------------------------------------------
|
| 352 |
|
| 353 |
+
/// Sync api_key fields to credential storage.
|
| 354 |
+
/// - `Some(RedactedSecret)` with value != "[REDACTED]" → save to credential storage
|
| 355 |
+
/// - `None` → clear from credential storage
|
| 356 |
/// - `Some(RedactedSecret)` with value == "[REDACTED]" → unchanged
|
| 357 |
pub fn sync_secrets(config: &AppConfig) -> Result<()> {
|
| 358 |
for provider in &config.providers {
|
koharu-llm/Cargo.toml
CHANGED
|
@@ -62,9 +62,6 @@ keyring = { workspace = true, features = ["windows-native"] }
|
|
| 62 |
[target.'cfg(target_os = "macos")'.dependencies]
|
| 63 |
keyring = { workspace = true, features = ["apple-native"] }
|
| 64 |
|
| 65 |
-
[target.'cfg(target_os = "linux")'.dependencies]
|
| 66 |
-
keyring = { workspace = true, features = ["sync-secret-service"] }
|
| 67 |
-
|
| 68 |
[build-dependencies]
|
| 69 |
anyhow = { workspace = true }
|
| 70 |
bindgen = "0.72.1"
|
|
|
|
| 62 |
[target.'cfg(target_os = "macos")'.dependencies]
|
| 63 |
keyring = { workspace = true, features = ["apple-native"] }
|
| 64 |
|
|
|
|
|
|
|
|
|
|
| 65 |
[build-dependencies]
|
| 66 |
anyhow = { workspace = true }
|
| 67 |
bindgen = "0.72.1"
|
koharu-llm/src/providers/credentials.rs
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::sync::Once;
|
| 2 |
+
|
| 3 |
+
use keyring::Entry;
|
| 4 |
+
|
| 5 |
+
const API_KEY_SERVICE: &str = "koharu";
|
| 6 |
+
|
| 7 |
+
static INIT_CREDENTIAL_STORE: Once = Once::new();
|
| 8 |
+
|
| 9 |
+
pub fn get_saved_api_key(provider: &str) -> anyhow::Result<Option<String>> {
|
| 10 |
+
let entry = provider_entry(provider)?;
|
| 11 |
+
match entry.get_password() {
|
| 12 |
+
Ok(value) => Ok(Some(value)),
|
| 13 |
+
Err(keyring::Error::NoEntry) => Ok(None),
|
| 14 |
+
Err(err) => Err(err.into()),
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
pub fn set_saved_api_key(provider: &str, api_key: &str) -> anyhow::Result<()> {
|
| 19 |
+
let entry = provider_entry(provider)?;
|
| 20 |
+
if api_key.trim().is_empty() {
|
| 21 |
+
return match entry.delete_credential() {
|
| 22 |
+
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
| 23 |
+
Err(err) => Err(err.into()),
|
| 24 |
+
};
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
entry.set_password(api_key)?;
|
| 28 |
+
Ok(())
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
fn provider_entry(provider: &str) -> anyhow::Result<Entry> {
|
| 32 |
+
INIT_CREDENTIAL_STORE.call_once(configure_platform_store);
|
| 33 |
+
|
| 34 |
+
let username = format!("llm_provider_api_key_{provider}");
|
| 35 |
+
Ok(Entry::new(API_KEY_SERVICE, &username)?)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
#[cfg(target_os = "linux")]
|
| 39 |
+
fn configure_platform_store() {
|
| 40 |
+
let root = koharu_runtime::default_app_data_root()
|
| 41 |
+
.as_std_path()
|
| 42 |
+
.join("secrets")
|
| 43 |
+
.join("keyring");
|
| 44 |
+
keyring::set_default_credential_builder(Box::new(filesystem::Builder::new(root)));
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
#[cfg(not(target_os = "linux"))]
|
| 48 |
+
fn configure_platform_store() {}
|
| 49 |
+
|
| 50 |
+
#[cfg(any(target_os = "linux", test))]
|
| 51 |
+
mod filesystem {
|
| 52 |
+
use std::fmt::Write as _;
|
| 53 |
+
use std::fs;
|
| 54 |
+
use std::path::{Path, PathBuf};
|
| 55 |
+
use std::sync::atomic::{AtomicU64, Ordering};
|
| 56 |
+
|
| 57 |
+
use keyring::credential::{Credential, CredentialApi, CredentialBuilderApi};
|
| 58 |
+
use keyring::{Error, Result};
|
| 59 |
+
|
| 60 |
+
static TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
| 61 |
+
|
| 62 |
+
#[derive(Debug)]
|
| 63 |
+
pub(super) struct Builder {
|
| 64 |
+
root: PathBuf,
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
impl Builder {
|
| 68 |
+
pub(super) fn new(root: impl Into<PathBuf>) -> Self {
|
| 69 |
+
Self { root: root.into() }
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
impl CredentialBuilderApi for Builder {
|
| 74 |
+
fn build(
|
| 75 |
+
&self,
|
| 76 |
+
target: Option<&str>,
|
| 77 |
+
service: &str,
|
| 78 |
+
user: &str,
|
| 79 |
+
) -> Result<Box<Credential>> {
|
| 80 |
+
Ok(Box::new(FileCredential {
|
| 81 |
+
root: self.root.clone(),
|
| 82 |
+
name: file_name(target, service, user),
|
| 83 |
+
}))
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
fn as_any(&self) -> &dyn std::any::Any {
|
| 87 |
+
self
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
#[derive(Debug)]
|
| 92 |
+
struct FileCredential {
|
| 93 |
+
root: PathBuf,
|
| 94 |
+
name: String,
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
impl FileCredential {
|
| 98 |
+
fn path(&self) -> PathBuf {
|
| 99 |
+
self.root.join(&self.name)
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
fn temp_path(&self) -> PathBuf {
|
| 103 |
+
self.root.join(format!(
|
| 104 |
+
"{}.tmp-{}-{}",
|
| 105 |
+
self.name,
|
| 106 |
+
std::process::id(),
|
| 107 |
+
TEMP_ID.fetch_add(1, Ordering::Relaxed)
|
| 108 |
+
))
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
impl CredentialApi for FileCredential {
|
| 113 |
+
fn set_secret(&self, secret: &[u8]) -> Result<()> {
|
| 114 |
+
fs::create_dir_all(&self.root).map_err(storage_error)?;
|
| 115 |
+
set_mode(&self.root, 0o700)?;
|
| 116 |
+
|
| 117 |
+
let path = self.path();
|
| 118 |
+
let temp_path = self.temp_path();
|
| 119 |
+
fs::write(&temp_path, secret).map_err(storage_error)?;
|
| 120 |
+
set_mode(&temp_path, 0o600)?;
|
| 121 |
+
|
| 122 |
+
fs::rename(&temp_path, &path).or_else(|err| {
|
| 123 |
+
let _ = fs::remove_file(&temp_path);
|
| 124 |
+
Err(storage_error(err))
|
| 125 |
+
})?;
|
| 126 |
+
set_mode(&path, 0o600)
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
fn get_secret(&self) -> Result<Vec<u8>> {
|
| 130 |
+
fs::read(self.path()).map_err(|err| match err.kind() {
|
| 131 |
+
std::io::ErrorKind::NotFound => Error::NoEntry,
|
| 132 |
+
_ => storage_error(err),
|
| 133 |
+
})
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
fn delete_credential(&self) -> Result<()> {
|
| 137 |
+
fs::remove_file(self.path()).map_err(|err| match err.kind() {
|
| 138 |
+
std::io::ErrorKind::NotFound => Error::NoEntry,
|
| 139 |
+
_ => storage_error(err),
|
| 140 |
+
})
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
fn as_any(&self) -> &dyn std::any::Any {
|
| 144 |
+
self
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
#[cfg(unix)]
|
| 149 |
+
fn set_mode(path: &Path, mode: u32) -> Result<()> {
|
| 150 |
+
use std::os::unix::fs::PermissionsExt;
|
| 151 |
+
|
| 152 |
+
fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(storage_error)
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
#[cfg(not(unix))]
|
| 156 |
+
fn set_mode(_path: &Path, _mode: u32) -> Result<()> {
|
| 157 |
+
Ok(())
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
fn storage_error(err: std::io::Error) -> Error {
|
| 161 |
+
Error::NoStorageAccess(Box::new(err))
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
fn file_name(target: Option<&str>, service: &str, user: &str) -> String {
|
| 165 |
+
let target = target
|
| 166 |
+
.map(|value| format!("some-{}", encode(value)))
|
| 167 |
+
.unwrap_or_else(|| "none".to_string());
|
| 168 |
+
format!(
|
| 169 |
+
"v1-target-{target}--service-{}--user-{}.secret",
|
| 170 |
+
encode(service),
|
| 171 |
+
encode(user)
|
| 172 |
+
)
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
fn encode(value: &str) -> String {
|
| 176 |
+
let mut encoded = String::with_capacity(value.len());
|
| 177 |
+
for byte in value.bytes() {
|
| 178 |
+
match byte {
|
| 179 |
+
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => {
|
| 180 |
+
encoded.push(byte as char);
|
| 181 |
+
}
|
| 182 |
+
_ => {
|
| 183 |
+
let _ = write!(&mut encoded, "%{byte:02X}");
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
encoded
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
#[cfg(test)]
|
| 191 |
+
mod tests {
|
| 192 |
+
use super::*;
|
| 193 |
+
|
| 194 |
+
#[test]
|
| 195 |
+
fn round_trips_secret_across_credentials() {
|
| 196 |
+
let dir = tempfile::tempdir().unwrap();
|
| 197 |
+
let builder = Builder::new(dir.path());
|
| 198 |
+
|
| 199 |
+
let first = builder
|
| 200 |
+
.build(None, "koharu", "llm_provider_api_key_openai")
|
| 201 |
+
.unwrap();
|
| 202 |
+
assert!(matches!(first.get_secret(), Err(Error::NoEntry)));
|
| 203 |
+
first.set_secret(b"sk-test").unwrap();
|
| 204 |
+
|
| 205 |
+
let second = builder
|
| 206 |
+
.build(None, "koharu", "llm_provider_api_key_openai")
|
| 207 |
+
.unwrap();
|
| 208 |
+
assert_eq!(second.get_secret().unwrap(), b"sk-test");
|
| 209 |
+
second.delete_credential().unwrap();
|
| 210 |
+
assert!(matches!(second.get_secret(), Err(Error::NoEntry)));
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
#[test]
|
| 214 |
+
fn file_names_escape_path_separators() {
|
| 215 |
+
let name = file_name(Some("target/value"), "service\\name", "user name");
|
| 216 |
+
|
| 217 |
+
assert!(name.contains("target%2Fvalue"));
|
| 218 |
+
assert!(name.contains("service%5Cname"));
|
| 219 |
+
assert!(name.contains("user%20name"));
|
| 220 |
+
assert!(!name.contains('/'));
|
| 221 |
+
assert!(!name.contains('\\'));
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
#[cfg(unix)]
|
| 225 |
+
#[test]
|
| 226 |
+
fn writes_private_permissions() {
|
| 227 |
+
use std::os::unix::fs::PermissionsExt;
|
| 228 |
+
|
| 229 |
+
let dir = tempfile::tempdir().unwrap();
|
| 230 |
+
let builder = Builder::new(dir.path());
|
| 231 |
+
let credential = builder
|
| 232 |
+
.build(None, "koharu", "llm_provider_api_key_openai")
|
| 233 |
+
.unwrap();
|
| 234 |
+
|
| 235 |
+
credential.set_secret(b"sk-test").unwrap();
|
| 236 |
+
|
| 237 |
+
let dir_mode = fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
|
| 238 |
+
let file_path =
|
| 239 |
+
dir.path()
|
| 240 |
+
.join(file_name(None, "koharu", "llm_provider_api_key_openai"));
|
| 241 |
+
let file_mode = fs::metadata(file_path).unwrap().permissions().mode() & 0o777;
|
| 242 |
+
|
| 243 |
+
assert_eq!(dir_mode, 0o700);
|
| 244 |
+
assert_eq!(file_mode, 0o600);
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
}
|
koharu-llm/src/providers/mod.rs
CHANGED
|
@@ -1,10 +1,8 @@
|
|
| 1 |
use std::future::Future;
|
| 2 |
use std::pin::Pin;
|
| 3 |
use std::sync::Arc;
|
| 4 |
-
use std::sync::atomic::{AtomicBool, Ordering};
|
| 5 |
|
| 6 |
use anyhow::Context;
|
| 7 |
-
use keyring::Entry;
|
| 8 |
use reqwest_middleware::ClientWithMiddleware;
|
| 9 |
|
| 10 |
use crate::prompt::{BLOCK_TAG_INSTRUCTIONS, system_prompt};
|
|
@@ -21,6 +19,7 @@ pub(crate) fn resolve_system_prompt(custom: Option<&str>, target_language: Langu
|
|
| 21 |
pub mod caiyun;
|
| 22 |
mod chat_completions;
|
| 23 |
pub mod claude;
|
|
|
|
| 24 |
pub mod deepl;
|
| 25 |
pub mod deepseek;
|
| 26 |
pub mod gemini;
|
|
@@ -28,10 +27,7 @@ pub mod google_translate;
|
|
| 28 |
pub mod openai;
|
| 29 |
pub mod openai_compatible;
|
| 30 |
|
| 31 |
-
|
| 32 |
-
pub const OPENAI_COMPATIBLE_ID: &str = "openai-compatible";
|
| 33 |
-
|
| 34 |
-
static NO_KEYRING: AtomicBool = AtomicBool::new(false);
|
| 35 |
|
| 36 |
#[derive(Debug, Clone, Copy)]
|
| 37 |
pub struct ProviderModelDescriptor {
|
|
@@ -78,62 +74,6 @@ pub struct ProviderDescriptor {
|
|
| 78 |
pub build: fn(ProviderConfig) -> anyhow::Result<Box<dyn AnyProvider>>,
|
| 79 |
}
|
| 80 |
|
| 81 |
-
pub fn disable_keyring() {
|
| 82 |
-
NO_KEYRING.store(true, Ordering::Relaxed);
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
fn env_key_var(provider: &str) -> String {
|
| 86 |
-
format!(
|
| 87 |
-
"KOHARU_{}_API_KEY",
|
| 88 |
-
provider.to_ascii_uppercase().replace('-', "_")
|
| 89 |
-
)
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
fn provider_key_entry(provider: &str) -> anyhow::Result<Entry> {
|
| 93 |
-
let username = format!("llm_provider_api_key_{provider}");
|
| 94 |
-
Ok(Entry::new(API_KEY_SERVICE, &username)?)
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
pub fn get_saved_api_key(provider: &str) -> anyhow::Result<Option<String>> {
|
| 98 |
-
if NO_KEYRING.load(Ordering::Relaxed) {
|
| 99 |
-
let var = env_key_var(provider);
|
| 100 |
-
return Ok(std::env::var(&var)
|
| 101 |
-
.ok()
|
| 102 |
-
.map(|v| v.trim().to_string())
|
| 103 |
-
.filter(|v| !v.is_empty()));
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
let entry = provider_key_entry(provider)?;
|
| 107 |
-
match entry.get_password() {
|
| 108 |
-
Ok(value) => Ok(Some(value)),
|
| 109 |
-
Err(keyring::Error::NoEntry) => Ok(None),
|
| 110 |
-
Err(err) => Err(err.into()),
|
| 111 |
-
}
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
pub fn set_saved_api_key(provider: &str, api_key: &str) -> anyhow::Result<()> {
|
| 115 |
-
if NO_KEYRING.load(Ordering::Relaxed) {
|
| 116 |
-
tracing::warn!(
|
| 117 |
-
provider,
|
| 118 |
-
"keyring is disabled; API key changes are not saved"
|
| 119 |
-
);
|
| 120 |
-
return Err(anyhow::anyhow!(
|
| 121 |
-
"keyring is disabled; API key cannot be saved"
|
| 122 |
-
));
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
let entry = provider_key_entry(provider)?;
|
| 126 |
-
if api_key.trim().is_empty() {
|
| 127 |
-
match entry.delete_credential() {
|
| 128 |
-
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
| 129 |
-
Err(err) => Err(err.into()),
|
| 130 |
-
}
|
| 131 |
-
} else {
|
| 132 |
-
entry.set_password(api_key)?;
|
| 133 |
-
Ok(())
|
| 134 |
-
}
|
| 135 |
-
}
|
| 136 |
-
|
| 137 |
pub async fn ensure_provider_success(
|
| 138 |
provider: &str,
|
| 139 |
response: reqwest::Response,
|
|
@@ -273,7 +213,7 @@ const PROVIDERS: &[ProviderDescriptor] = &[
|
|
| 273 |
build: build_caiyun_mt_provider,
|
| 274 |
},
|
| 275 |
ProviderDescriptor {
|
| 276 |
-
id:
|
| 277 |
name: "OpenAI-compatible",
|
| 278 |
requires_api_key: false,
|
| 279 |
requires_base_url: true,
|
|
@@ -395,7 +335,7 @@ fn build_openai_compatible_provider(
|
|
| 395 |
) -> anyhow::Result<Box<dyn AnyProvider>> {
|
| 396 |
Ok(Box::new(openai_compatible::OpenAiCompatibleProvider {
|
| 397 |
http_client: Arc::clone(&config.http_client),
|
| 398 |
-
base_url: required_base_url(&config,
|
| 399 |
api_key: config.api_key,
|
| 400 |
temperature: config.temperature,
|
| 401 |
max_tokens: config.max_tokens,
|
|
@@ -428,7 +368,7 @@ fn build_caiyun_mt_provider(config: ProviderConfig) -> anyhow::Result<Box<dyn An
|
|
| 428 |
|
| 429 |
fn discover_openai_compatible_models(config: ProviderConfig) -> ProviderDiscoveryFuture {
|
| 430 |
Box::pin(async move {
|
| 431 |
-
let base_url = required_base_url(&config,
|
| 432 |
let models = openai_compatible::list_models(
|
| 433 |
config.http_client,
|
| 434 |
&base_url,
|
|
|
|
| 1 |
use std::future::Future;
|
| 2 |
use std::pin::Pin;
|
| 3 |
use std::sync::Arc;
|
|
|
|
| 4 |
|
| 5 |
use anyhow::Context;
|
|
|
|
| 6 |
use reqwest_middleware::ClientWithMiddleware;
|
| 7 |
|
| 8 |
use crate::prompt::{BLOCK_TAG_INSTRUCTIONS, system_prompt};
|
|
|
|
| 19 |
pub mod caiyun;
|
| 20 |
mod chat_completions;
|
| 21 |
pub mod claude;
|
| 22 |
+
mod credentials;
|
| 23 |
pub mod deepl;
|
| 24 |
pub mod deepseek;
|
| 25 |
pub mod gemini;
|
|
|
|
| 27 |
pub mod openai;
|
| 28 |
pub mod openai_compatible;
|
| 29 |
|
| 30 |
+
pub use credentials::{get_saved_api_key, set_saved_api_key};
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
#[derive(Debug, Clone, Copy)]
|
| 33 |
pub struct ProviderModelDescriptor {
|
|
|
|
| 74 |
pub build: fn(ProviderConfig) -> anyhow::Result<Box<dyn AnyProvider>>,
|
| 75 |
}
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
pub async fn ensure_provider_success(
|
| 78 |
provider: &str,
|
| 79 |
response: reqwest::Response,
|
|
|
|
| 213 |
build: build_caiyun_mt_provider,
|
| 214 |
},
|
| 215 |
ProviderDescriptor {
|
| 216 |
+
id: "openai-compatible",
|
| 217 |
name: "OpenAI-compatible",
|
| 218 |
requires_api_key: false,
|
| 219 |
requires_base_url: true,
|
|
|
|
| 335 |
) -> anyhow::Result<Box<dyn AnyProvider>> {
|
| 336 |
Ok(Box::new(openai_compatible::OpenAiCompatibleProvider {
|
| 337 |
http_client: Arc::clone(&config.http_client),
|
| 338 |
+
base_url: required_base_url(&config, "openai-compatible")?,
|
| 339 |
api_key: config.api_key,
|
| 340 |
temperature: config.temperature,
|
| 341 |
max_tokens: config.max_tokens,
|
|
|
|
| 368 |
|
| 369 |
fn discover_openai_compatible_models(config: ProviderConfig) -> ProviderDiscoveryFuture {
|
| 370 |
Box::pin(async move {
|
| 371 |
+
let base_url = required_base_url(&config, "openai-compatible")?;
|
| 372 |
let models = openai_compatible::list_models(
|
| 373 |
config.http_client,
|
| 374 |
&base_url,
|
koharu-ml/Cargo.toml
CHANGED
|
@@ -34,7 +34,6 @@ num_cpus = { workspace = true }
|
|
| 34 |
rayon = { workspace = true }
|
| 35 |
libloading = { workspace = true }
|
| 36 |
minijinja = { workspace = true }
|
| 37 |
-
keyring = { workspace = true }
|
| 38 |
cudarc = { workspace = true, optional = true }
|
| 39 |
objc2 = { workspace = true, optional = true }
|
| 40 |
objc2-metal = { workspace = true, optional = true }
|
|
@@ -42,15 +41,6 @@ objc2-metal-performance-shaders = { workspace = true, optional = true }
|
|
| 42 |
objc2-metal-performance-shaders-graph = { workspace = true, optional = true }
|
| 43 |
objc2-foundation = { workspace = true, optional = true }
|
| 44 |
|
| 45 |
-
[target.'cfg(windows)'.dependencies]
|
| 46 |
-
keyring = { workspace = true, features = ["windows-native"] }
|
| 47 |
-
|
| 48 |
-
[target.'cfg(target_os = "macos")'.dependencies]
|
| 49 |
-
keyring = { workspace = true, features = ["apple-native"] }
|
| 50 |
-
|
| 51 |
-
[target.'cfg(target_os = "linux")'.dependencies]
|
| 52 |
-
keyring = { workspace = true, features = ["sync-secret-service"] }
|
| 53 |
-
|
| 54 |
[features]
|
| 55 |
cuda = [
|
| 56 |
"candle-core/cuda",
|
|
|
|
| 34 |
rayon = { workspace = true }
|
| 35 |
libloading = { workspace = true }
|
| 36 |
minijinja = { workspace = true }
|
|
|
|
| 37 |
cudarc = { workspace = true, optional = true }
|
| 38 |
objc2 = { workspace = true, optional = true }
|
| 39 |
objc2-metal = { workspace = true, optional = true }
|
|
|
|
| 41 |
objc2-metal-performance-shaders-graph = { workspace = true, optional = true }
|
| 42 |
objc2-foundation = { workspace = true, optional = true }
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
[features]
|
| 45 |
cuda = [
|
| 46 |
"candle-core/cuda",
|
koharu/src/app.rs
CHANGED
|
@@ -62,10 +62,6 @@ pub async fn run() -> Result<()> {
|
|
| 62 |
.with(crate::tracing::TimingLayer::new())
|
| 63 |
.init();
|
| 64 |
|
| 65 |
-
if cli.no_keyring {
|
| 66 |
-
koharu_llm::providers::disable_keyring();
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
let config: AppConfig = app_config::load()?;
|
| 70 |
let http = RuntimeHttpConfig {
|
| 71 |
connect_timeout_secs: config.http.connect_timeout.max(1),
|
|
|
|
| 62 |
.with(crate::tracing::TimingLayer::new())
|
| 63 |
.init();
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
let config: AppConfig = app_config::load()?;
|
| 66 |
let http = RuntimeHttpConfig {
|
| 67 |
connect_timeout_secs: config.http.connect_timeout.max(1),
|
koharu/src/cli.rs
CHANGED
|
@@ -16,8 +16,6 @@ pub(crate) struct Cli {
|
|
| 16 |
pub(crate) host: Option<String>,
|
| 17 |
#[arg(long, help = "Run without GUI")]
|
| 18 |
pub(crate) headless: bool,
|
| 19 |
-
#[arg(long, help = "Use env vars for API keys instead of keyring")]
|
| 20 |
-
pub(crate) no_keyring: bool,
|
| 21 |
#[arg(long, help = "Enable debug console output")]
|
| 22 |
pub(crate) debug: bool,
|
| 23 |
}
|
|
|
|
| 16 |
pub(crate) host: Option<String>,
|
| 17 |
#[arg(long, help = "Run without GUI")]
|
| 18 |
pub(crate) headless: bool,
|
|
|
|
|
|
|
| 19 |
#[arg(long, help = "Enable debug console output")]
|
| 20 |
pub(crate) debug: bool,
|
| 21 |
}
|