Karrot commited on
Commit
4631bae
·
1 Parent(s): ef8799f

fix: use keyring credential deletion API

Browse files

feat: fix keyring, and add language support for api provider and language translation for api keys settings.

feat: add provider quota exceeded error handling and localization support

Cargo.lock CHANGED
@@ -1393,6 +1393,27 @@ version = "2.10.0"
1393
  source = "registry+https://github.com/rust-lang/crates.io-index"
1394
  checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
1395
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1396
  [[package]]
1397
  name = "der"
1398
  version = "0.7.10"
@@ -3773,6 +3794,21 @@ dependencies = [
3773
  "unicode-segmentation",
3774
  ]
3775
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3776
  [[package]]
3777
  name = "koharu"
3778
  version = "0.37.0"
@@ -3857,6 +3893,7 @@ dependencies = [
3857
  "objc2-metal",
3858
  "objc2-metal-performance-shaders",
3859
  "objc2-metal-performance-shaders-graph",
 
3860
  "rustfft",
3861
  "serde",
3862
  "serde_json",
@@ -3874,6 +3911,7 @@ dependencies = [
3874
  "anyhow",
3875
  "image",
3876
  "imageproc",
 
3877
  "koharu-api",
3878
  "koharu-ml",
3879
  "koharu-renderer",
@@ -4028,6 +4066,15 @@ version = "0.2.182"
4028
  source = "registry+https://github.com/rust-lang/crates.io-index"
4029
  checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
4030
 
 
 
 
 
 
 
 
 
 
4031
  [[package]]
4032
  name = "libfuzzer-sys"
4033
  version = "0.4.12"
@@ -4434,7 +4481,7 @@ dependencies = [
4434
  "openssl-probe",
4435
  "openssl-sys",
4436
  "schannel",
4437
- "security-framework",
4438
  "security-framework-sys",
4439
  "tempfile",
4440
  ]
@@ -6268,7 +6315,7 @@ dependencies = [
6268
  "openssl-probe",
6269
  "rustls-pki-types",
6270
  "schannel",
6271
- "security-framework",
6272
  ]
6273
 
6274
  [[package]]
@@ -6296,7 +6343,7 @@ dependencies = [
6296
  "rustls-native-certs",
6297
  "rustls-platform-verifier-android",
6298
  "rustls-webpki",
6299
- "security-framework",
6300
  "security-framework-sys",
6301
  "webpki-root-certs",
6302
  "windows-sys 0.61.2",
@@ -6457,6 +6504,19 @@ version = "1.2.0"
6457
  source = "registry+https://github.com/rust-lang/crates.io-index"
6458
  checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
6459
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6460
  [[package]]
6461
  name = "security-framework"
6462
  version = "3.7.0"
@@ -9568,6 +9628,20 @@ name = "zeroize"
9568
  version = "1.8.2"
9569
  source = "registry+https://github.com/rust-lang/crates.io-index"
9570
  checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9571
 
9572
  [[package]]
9573
  name = "zerotrie"
 
1393
  source = "registry+https://github.com/rust-lang/crates.io-index"
1394
  checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
1395
 
1396
+ [[package]]
1397
+ name = "dbus"
1398
+ version = "0.9.10"
1399
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1400
+ checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
1401
+ dependencies = [
1402
+ "libc",
1403
+ "libdbus-sys",
1404
+ "windows-sys 0.59.0",
1405
+ ]
1406
+
1407
+ [[package]]
1408
+ name = "dbus-secret-service"
1409
+ version = "4.1.0"
1410
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1411
+ checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
1412
+ dependencies = [
1413
+ "dbus",
1414
+ "zeroize",
1415
+ ]
1416
+
1417
  [[package]]
1418
  name = "der"
1419
  version = "0.7.10"
 
3794
  "unicode-segmentation",
3795
  ]
3796
 
3797
+ [[package]]
3798
+ name = "keyring"
3799
+ version = "3.6.3"
3800
+ source = "registry+https://github.com/rust-lang/crates.io-index"
3801
+ checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
3802
+ dependencies = [
3803
+ "byteorder",
3804
+ "dbus-secret-service",
3805
+ "log",
3806
+ "security-framework 2.11.1",
3807
+ "security-framework 3.7.0",
3808
+ "windows-sys 0.60.2",
3809
+ "zeroize",
3810
+ ]
3811
+
3812
  [[package]]
3813
  name = "koharu"
3814
  version = "0.37.0"
 
3893
  "objc2-metal",
3894
  "objc2-metal-performance-shaders",
3895
  "objc2-metal-performance-shaders-graph",
3896
+ "reqwest 0.13.2",
3897
  "rustfft",
3898
  "serde",
3899
  "serde_json",
 
3911
  "anyhow",
3912
  "image",
3913
  "imageproc",
3914
+ "keyring",
3915
  "koharu-api",
3916
  "koharu-ml",
3917
  "koharu-renderer",
 
4066
  source = "registry+https://github.com/rust-lang/crates.io-index"
4067
  checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
4068
 
4069
+ [[package]]
4070
+ name = "libdbus-sys"
4071
+ version = "0.2.7"
4072
+ source = "registry+https://github.com/rust-lang/crates.io-index"
4073
+ checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
4074
+ dependencies = [
4075
+ "pkg-config",
4076
+ ]
4077
+
4078
  [[package]]
4079
  name = "libfuzzer-sys"
4080
  version = "0.4.12"
 
4481
  "openssl-probe",
4482
  "openssl-sys",
4483
  "schannel",
4484
+ "security-framework 3.7.0",
4485
  "security-framework-sys",
4486
  "tempfile",
4487
  ]
 
6315
  "openssl-probe",
6316
  "rustls-pki-types",
6317
  "schannel",
6318
+ "security-framework 3.7.0",
6319
  ]
6320
 
6321
  [[package]]
 
6343
  "rustls-native-certs",
6344
  "rustls-platform-verifier-android",
6345
  "rustls-webpki",
6346
+ "security-framework 3.7.0",
6347
  "security-framework-sys",
6348
  "webpki-root-certs",
6349
  "windows-sys 0.61.2",
 
6504
  source = "registry+https://github.com/rust-lang/crates.io-index"
6505
  checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
6506
 
6507
+ [[package]]
6508
+ name = "security-framework"
6509
+ version = "2.11.1"
6510
+ source = "registry+https://github.com/rust-lang/crates.io-index"
6511
+ checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
6512
+ dependencies = [
6513
+ "bitflags 2.11.0",
6514
+ "core-foundation 0.9.4",
6515
+ "core-foundation-sys",
6516
+ "libc",
6517
+ "security-framework-sys",
6518
+ ]
6519
+
6520
  [[package]]
6521
  name = "security-framework"
6522
  version = "3.7.0"
 
9628
  version = "1.8.2"
9629
  source = "registry+https://github.com/rust-lang/crates.io-index"
9630
  checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
9631
+ dependencies = [
9632
+ "zeroize_derive",
9633
+ ]
9634
+
9635
+ [[package]]
9636
+ name = "zeroize_derive"
9637
+ version = "1.4.3"
9638
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9639
+ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
9640
+ dependencies = [
9641
+ "proc-macro2",
9642
+ "quote",
9643
+ "syn 2.0.117",
9644
+ ]
9645
 
9646
  [[package]]
9647
  name = "zerotrie"
koharu-ml/Cargo.toml CHANGED
@@ -24,6 +24,7 @@ candle-nn = { workspace = true }
24
  tokenizers = { workspace = true }
25
  serde = { workspace = true }
26
  serde_json = { workspace = true }
 
27
  strum = { workspace = true }
28
  tracing = { workspace = true }
29
  tracing-subscriber = { workspace = true }
 
24
  tokenizers = { workspace = true }
25
  serde = { workspace = true }
26
  serde_json = { workspace = true }
27
+ reqwest = { workspace = true }
28
  strum = { workspace = true }
29
  tracing = { workspace = true }
30
  tracing-subscriber = { workspace = true }
koharu-ml/src/llm/facade.rs CHANGED
@@ -42,7 +42,10 @@ impl ModelInfo {
42
  pub fn api(provider_id: &'static str, model_id: &str) -> Self {
43
  Self {
44
  id: format!("{provider_id}:{model_id}"),
45
- languages: vec![],
 
 
 
46
  source: provider_id,
47
  }
48
  }
 
42
  pub fn api(provider_id: &'static str, model_id: &str) -> Self {
43
  Self {
44
  id: format!("{provider_id}:{model_id}"),
45
+ languages: ["en-US", "zh-CN", "zh-TW", "ja-JP", "ru-RU", "es-ES"]
46
+ .into_iter()
47
+ .map(str::to_string)
48
+ .collect(),
49
  source: provider_id,
50
  }
51
  }
koharu-ml/src/llm/provider/claude.rs CHANGED
@@ -5,7 +5,7 @@ use serde::Serialize;
5
 
6
  use koharu_http::http::http_client;
7
 
8
- use super::{AnyProvider, system_prompt};
9
 
10
  pub struct ClaudeProvider {
11
  pub api_key: String,
@@ -43,15 +43,17 @@ impl AnyProvider for ClaudeProvider {
43
  }],
44
  };
45
 
46
- let resp: serde_json::Value = http_client()
47
  .post("https://api.anthropic.com/v1/messages")
48
  .header("x-api-key", &self.api_key)
49
  .header("anthropic-version", "2023-06-01")
50
  .header("content-type", "application/json")
51
  .body(serde_json::to_vec(&body)?)
52
  .send()
 
 
 
53
  .await?
54
- .error_for_status()?
55
  .json()
56
  .await?;
57
 
 
5
 
6
  use koharu_http::http::http_client;
7
 
8
+ use super::{AnyProvider, ensure_provider_success, system_prompt};
9
 
10
  pub struct ClaudeProvider {
11
  pub api_key: String,
 
43
  }],
44
  };
45
 
46
+ let response = http_client()
47
  .post("https://api.anthropic.com/v1/messages")
48
  .header("x-api-key", &self.api_key)
49
  .header("anthropic-version", "2023-06-01")
50
  .header("content-type", "application/json")
51
  .body(serde_json::to_vec(&body)?)
52
  .send()
53
+ .await?;
54
+
55
+ let resp: serde_json::Value = ensure_provider_success("claude", response)
56
  .await?
 
57
  .json()
58
  .await?;
59
 
koharu-ml/src/llm/provider/gemini.rs CHANGED
@@ -5,7 +5,7 @@ use serde::Serialize;
5
 
6
  use koharu_http::http::http_client;
7
 
8
- use super::{AnyProvider, system_prompt};
9
 
10
  pub struct GeminiProvider {
11
  pub api_key: String,
@@ -58,13 +58,15 @@ impl AnyProvider for GeminiProvider {
58
  }],
59
  };
60
 
61
- let resp: serde_json::Value = http_client()
62
  .post(&url)
63
  .header("content-type", "application/json")
64
  .body(serde_json::to_vec(&body)?)
65
  .send()
 
 
 
66
  .await?
67
- .error_for_status()?
68
  .json()
69
  .await?;
70
 
 
5
 
6
  use koharu_http::http::http_client;
7
 
8
+ use super::{AnyProvider, ensure_provider_success, system_prompt};
9
 
10
  pub struct GeminiProvider {
11
  pub api_key: String,
 
58
  }],
59
  };
60
 
61
+ let response = http_client()
62
  .post(&url)
63
  .header("content-type", "application/json")
64
  .body(serde_json::to_vec(&body)?)
65
  .send()
66
+ .await?;
67
+
68
+ let resp: serde_json::Value = ensure_provider_success("gemini", response)
69
  .await?
 
70
  .json()
71
  .await?;
72
 
koharu-ml/src/llm/provider/mod.rs CHANGED
@@ -1,6 +1,8 @@
1
  use std::future::Future;
2
  use std::pin::Pin;
3
 
 
 
4
  pub mod claude;
5
  pub mod gemini;
6
  pub mod openai;
@@ -14,6 +16,34 @@ pub fn system_prompt(target_language: &str) -> String {
14
  SYSTEM_PROMPT_TEMPLATE.replace("{target_language}", target_language)
15
  }
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  pub trait AnyProvider: Send + Sync {
18
  fn translate<'a>(
19
  &'a self,
 
1
  use std::future::Future;
2
  use std::pin::Pin;
3
 
4
+ use anyhow::Context;
5
+
6
  pub mod claude;
7
  pub mod gemini;
8
  pub mod openai;
 
16
  SYSTEM_PROMPT_TEMPLATE.replace("{target_language}", target_language)
17
  }
18
 
19
+ pub async fn ensure_provider_success(
20
+ provider: &str,
21
+ response: reqwest::Response,
22
+ ) -> anyhow::Result<reqwest::Response> {
23
+ let status = response.status();
24
+ if status.is_success() {
25
+ return Ok(response);
26
+ }
27
+
28
+ let body = response
29
+ .text()
30
+ .await
31
+ .with_context(|| format!("Failed to read {provider} error response body"))?;
32
+ let body_lower = body.to_ascii_lowercase();
33
+ let quota_exceeded = status.as_u16() == 429
34
+ || body_lower.contains("insufficient_quota")
35
+ || body_lower.contains("quota")
36
+ || body_lower.contains("resource_exhausted")
37
+ || body_lower.contains("rate limit exceeded")
38
+ || body_lower.contains("credit balance is too low");
39
+
40
+ if quota_exceeded {
41
+ anyhow::bail!("provider_quota_exceeded:{provider}");
42
+ }
43
+
44
+ anyhow::bail!("{provider} API request failed ({status}): {body}");
45
+ }
46
+
47
  pub trait AnyProvider: Send + Sync {
48
  fn translate<'a>(
49
  &'a self,
koharu-ml/src/llm/provider/openai.rs CHANGED
@@ -5,7 +5,7 @@ use serde::Serialize;
5
 
6
  use koharu_http::http::http_client;
7
 
8
- use super::{AnyProvider, system_prompt};
9
 
10
  pub struct OpenAiProvider {
11
  pub api_key: String,
@@ -45,14 +45,16 @@ impl AnyProvider for OpenAiProvider {
45
  ],
46
  };
47
 
48
- let resp: serde_json::Value = http_client()
49
  .post("https://api.openai.com/v1/chat/completions")
50
  .bearer_auth(&self.api_key)
51
  .header("content-type", "application/json")
52
  .body(serde_json::to_vec(&body)?)
53
  .send()
 
 
 
54
  .await?
55
- .error_for_status()?
56
  .json()
57
  .await?;
58
 
 
5
 
6
  use koharu_http::http::http_client;
7
 
8
+ use super::{AnyProvider, ensure_provider_success, system_prompt};
9
 
10
  pub struct OpenAiProvider {
11
  pub api_key: String,
 
45
  ],
46
  };
47
 
48
+ let response = http_client()
49
  .post("https://api.openai.com/v1/chat/completions")
50
  .bearer_auth(&self.api_key)
51
  .header("content-type", "application/json")
52
  .body(serde_json::to_vec(&body)?)
53
  .send()
54
+ .await?;
55
+
56
+ let resp: serde_json::Value = ensure_provider_success("openai", response)
57
  .await?
 
58
  .json()
59
  .await?;
60
 
koharu-pipeline/Cargo.toml CHANGED
@@ -29,3 +29,12 @@ strum = { workspace = true }
29
  tokio = { workspace = true }
30
  tracing = { workspace = true }
31
  keyring = { workspace = true }
 
 
 
 
 
 
 
 
 
 
29
  tokio = { workspace = true }
30
  tracing = { workspace = true }
31
  keyring = { workspace = true }
32
+
33
+ [target.'cfg(windows)'.dependencies]
34
+ keyring = { workspace = true, features = ["windows-native"] }
35
+
36
+ [target.'cfg(target_os = "macos")'.dependencies]
37
+ keyring = { workspace = true, features = ["apple-native"] }
38
+
39
+ [target.'cfg(target_os = "linux")'.dependencies]
40
+ keyring = { workspace = true, features = ["sync-secret-service"] }
koharu-pipeline/src/ops/llm.rs CHANGED
@@ -16,6 +16,11 @@ use crate::{AppResources, state_tx};
16
  const API_KEY_SERVICE: &str = "koharu";
17
 
18
  fn provider_key_entry(provider: &str) -> anyhow::Result<Entry> {
 
 
 
 
 
19
  let username = format!("llm-provider-api-key:{provider}");
20
  Ok(Entry::new(API_KEY_SERVICE, &username)?)
21
  }
@@ -24,32 +29,76 @@ pub(crate) fn get_saved_api_key(provider: &str) -> anyhow::Result<Option<String>
24
  let entry = provider_key_entry(provider)?;
25
  match entry.get_password() {
26
  Ok(value) => Ok(Some(value)),
27
- Err(keyring::Error::NoEntry) => Ok(None),
 
 
 
 
 
 
 
 
 
 
 
28
  Err(err) => Err(err.into()),
29
  }
30
  }
31
 
32
  fn set_saved_api_key(provider: &str, api_key: &str) -> anyhow::Result<()> {
33
  let entry = provider_key_entry(provider)?;
 
34
  if api_key.trim().is_empty() {
35
- match entry.delete_password() {
36
- Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
37
- Err(err) => Err(err.into()),
38
  }
 
 
39
  } else {
40
  entry.set_password(api_key)?;
 
41
  Ok(())
42
  }
43
  }
44
 
 
45
  pub async fn get_api_key(_state: AppResources, payload: ApiKeyGetPayload) -> anyhow::Result<ApiKeyResult> {
46
- Ok(ApiKeyResult {
47
- api_key: get_saved_api_key(&payload.provider)?,
48
- })
 
 
 
 
 
 
 
 
 
 
 
 
49
  }
50
 
 
51
  pub async fn set_api_key(_state: AppResources, payload: ApiKeySetPayload) -> anyhow::Result<()> {
52
- set_saved_api_key(&payload.provider, &payload.api_key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  }
54
 
55
  pub async fn llm_list(
 
16
  const API_KEY_SERVICE: &str = "koharu";
17
 
18
  fn provider_key_entry(provider: &str) -> anyhow::Result<Entry> {
19
+ let username = format!("llm_provider_api_key_{provider}");
20
+ Ok(Entry::new(API_KEY_SERVICE, &username)?)
21
+ }
22
+
23
+ fn legacy_provider_key_entry(provider: &str) -> anyhow::Result<Entry> {
24
  let username = format!("llm-provider-api-key:{provider}");
25
  Ok(Entry::new(API_KEY_SERVICE, &username)?)
26
  }
 
29
  let entry = provider_key_entry(provider)?;
30
  match entry.get_password() {
31
  Ok(value) => Ok(Some(value)),
32
+ Err(keyring::Error::NoEntry) => {
33
+ let legacy_entry = legacy_provider_key_entry(provider)?;
34
+ match legacy_entry.get_password() {
35
+ Ok(value) => {
36
+ let _ = entry.set_password(&value);
37
+ let _ = legacy_entry.delete_credential();
38
+ Ok(Some(value))
39
+ }
40
+ Err(keyring::Error::NoEntry) => Ok(None),
41
+ Err(err) => Err(err.into()),
42
+ }
43
+ }
44
  Err(err) => Err(err.into()),
45
  }
46
  }
47
 
48
  fn set_saved_api_key(provider: &str, api_key: &str) -> anyhow::Result<()> {
49
  let entry = provider_key_entry(provider)?;
50
+ let legacy_entry = legacy_provider_key_entry(provider)?;
51
  if api_key.trim().is_empty() {
52
+ match entry.delete_credential() {
53
+ Ok(()) | Err(keyring::Error::NoEntry) => {}
54
+ Err(err) => return Err(anyhow::Error::from(err)),
55
  }
56
+ let _ = legacy_entry.delete_credential();
57
+ Ok(())
58
  } else {
59
  entry.set_password(api_key)?;
60
+ let _ = legacy_entry.delete_credential();
61
  Ok(())
62
  }
63
  }
64
 
65
+ #[instrument(level = "info", skip_all, fields(provider = %payload.provider))]
66
  pub async fn get_api_key(_state: AppResources, payload: ApiKeyGetPayload) -> anyhow::Result<ApiKeyResult> {
67
+ tracing::info!("loading API key from keyring");
68
+ match get_saved_api_key(&payload.provider) {
69
+ Ok(Some(key)) => {
70
+ tracing::info!(len = key.len(), "API key found in keyring");
71
+ Ok(ApiKeyResult { api_key: Some(key) })
72
+ }
73
+ Ok(None) => {
74
+ tracing::info!("no API key found in keyring");
75
+ Ok(ApiKeyResult { api_key: None })
76
+ }
77
+ Err(err) => {
78
+ tracing::error!(%err, "keyring read failed");
79
+ Err(err)
80
+ }
81
+ }
82
  }
83
 
84
+ #[instrument(level = "info", skip_all, fields(provider = %payload.provider))]
85
  pub async fn set_api_key(_state: AppResources, payload: ApiKeySetPayload) -> anyhow::Result<()> {
86
+ let is_empty = payload.api_key.trim().is_empty();
87
+ tracing::info!(is_empty, "saving API key to keyring");
88
+ match set_saved_api_key(&payload.provider, &payload.api_key) {
89
+ Ok(()) => {
90
+ if is_empty {
91
+ tracing::info!("API key deleted from keyring");
92
+ } else {
93
+ tracing::info!("API key saved to keyring");
94
+ }
95
+ Ok(())
96
+ }
97
+ Err(err) => {
98
+ tracing::error!(%err, "keyring write failed");
99
+ Err(err)
100
+ }
101
+ }
102
  }
103
 
104
  pub async fn llm_list(
ui/app/(app)/settings/page.tsx CHANGED
@@ -1,6 +1,6 @@
1
  'use client'
2
 
3
- import { useEffect, useMemo, useState } from 'react'
4
  import { useTheme } from 'next-themes'
5
  import { useTranslation } from 'react-i18next'
6
  import Link from 'next/link'
@@ -33,9 +33,9 @@ const THEME_OPTIONS = [
33
  ] as const
34
 
35
  const API_PROVIDERS = [
36
- { id: 'openai', name: 'OpenAI' },
37
- { id: 'gemini', name: 'Gemini' },
38
- { id: 'claude', name: 'Claude' },
39
  ] as const
40
 
41
  export default function SettingsPage() {
@@ -49,6 +49,8 @@ export default function SettingsPage() {
49
  const apiKeys = usePreferencesStore((state) => state.apiKeys)
50
  const setApiKey = usePreferencesStore((state) => state.setApiKey)
51
  const [visibleKeys, setVisibleKeys] = useState<Record<string, boolean>>({})
 
 
52
 
53
  useEffect(() => {
54
  if (!isTauri()) return
@@ -72,7 +74,9 @@ export default function SettingsPage() {
72
  for (const { id } of API_PROVIDERS) {
73
  try {
74
  const key = await api.getApiKey(id)
75
- setApiKey(id, key ?? '')
 
 
76
  } catch (error) {
77
  console.error(`Failed to load API key for ${id}`, error)
78
  }
@@ -82,9 +86,51 @@ export default function SettingsPage() {
82
  void loadApiKeys()
83
  }, [setApiKey])
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  const handleApiKeyChange = (provider: string, value: string) => {
86
  setApiKey(provider, value)
87
- void api.setApiKey(provider, value)
 
 
 
 
 
 
 
 
 
 
88
  }
89
 
90
  return (
@@ -190,10 +236,10 @@ export default function SettingsPage() {
190
  {/* API Keys Section */}
191
  <section className='mb-8'>
192
  <h2 className='text-foreground mb-1 text-sm font-bold'>
193
- Provider Api Keys
194
  </h2>
195
  <p className='text-muted-foreground mb-4 text-sm'>
196
- Manage your API keys for different providers. Your keys are stored securely on your device and are never shared with anyone.
197
  </p>
198
  <div className='space-y-3'>
199
  {API_PROVIDERS.map(({ id, name }) => (
@@ -204,6 +250,7 @@ export default function SettingsPage() {
204
  type={visibleKeys[id] ? 'text' : 'password'}
205
  value={apiKeys[id] ?? ''}
206
  onChange={(e) => handleApiKeyChange(id, e.target.value)}
 
207
  placeholder='Enter API key'
208
  className='border-border bg-card text-foreground placeholder:text-muted-foreground focus:ring-primary w-full rounded-md border px-3 py-1.5 pr-9 text-sm focus:ring-1 focus:outline-none'
209
  />
@@ -220,6 +267,12 @@ export default function SettingsPage() {
220
  <EyeIcon className='size-4' />
221
  )}
222
  </button>
 
 
 
 
 
 
223
  </div>
224
  </div>
225
  ))}
 
1
  'use client'
2
 
3
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
  import { useTheme } from 'next-themes'
5
  import { useTranslation } from 'react-i18next'
6
  import Link from 'next/link'
 
33
  ] as const
34
 
35
  const API_PROVIDERS = [
36
+ { id: 'openai', name: 'OpenAI', free_tier: false},
37
+ { id: 'gemini', name: 'Gemini', free_tier: true},
38
+ { id: 'claude', name: 'Claude', free_tier: false},
39
  ] as const
40
 
41
  export default function SettingsPage() {
 
49
  const apiKeys = usePreferencesStore((state) => state.apiKeys)
50
  const setApiKey = usePreferencesStore((state) => state.setApiKey)
51
  const [visibleKeys, setVisibleKeys] = useState<Record<string, boolean>>({})
52
+ const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
53
+ const pendingApiKeysRef = useRef<Record<string, string>>({})
54
 
55
  useEffect(() => {
56
  if (!isTauri()) return
 
74
  for (const { id } of API_PROVIDERS) {
75
  try {
76
  const key = await api.getApiKey(id)
77
+ if (pendingApiKeysRef.current[id] === undefined) {
78
+ setApiKey(id, key ?? '')
79
+ }
80
  } catch (error) {
81
  console.error(`Failed to load API key for ${id}`, error)
82
  }
 
86
  void loadApiKeys()
87
  }, [setApiKey])
88
 
89
+ const persistApiKey = async (provider: string, value: string) => {
90
+ try {
91
+ await api.setApiKey(provider, value)
92
+ } catch (error) {
93
+ console.error(`Failed to save API key for ${provider}`, error)
94
+ }
95
+ }
96
+
97
+ const flushApiKeySave = (provider: string) => {
98
+ const existingTimer = saveTimersRef.current[provider]
99
+ if (existingTimer) {
100
+ clearTimeout(existingTimer)
101
+ delete saveTimersRef.current[provider]
102
+ }
103
+
104
+ const pendingValue = pendingApiKeysRef.current[provider]
105
+ if (pendingValue === undefined) {
106
+ return
107
+ }
108
+
109
+ delete pendingApiKeysRef.current[provider]
110
+ void persistApiKey(provider, pendingValue)
111
+ }
112
+
113
+ useEffect(() => {
114
+ return () => {
115
+ Object.keys(saveTimersRef.current).forEach((provider) => {
116
+ flushApiKeySave(provider)
117
+ })
118
+ }
119
+ }, [])
120
+
121
  const handleApiKeyChange = (provider: string, value: string) => {
122
  setApiKey(provider, value)
123
+ pendingApiKeysRef.current[provider] = value
124
+
125
+ const existingTimer = saveTimersRef.current[provider]
126
+ if (existingTimer) {
127
+ clearTimeout(existingTimer)
128
+ }
129
+
130
+ saveTimersRef.current[provider] = setTimeout(() => {
131
+ delete saveTimersRef.current[provider]
132
+ flushApiKeySave(provider)
133
+ }, 300)
134
  }
135
 
136
  return (
 
236
  {/* API Keys Section */}
237
  <section className='mb-8'>
238
  <h2 className='text-foreground mb-1 text-sm font-bold'>
239
+ {t('settings.apiKeys')}
240
  </h2>
241
  <p className='text-muted-foreground mb-4 text-sm'>
242
+ {t('settings.apiKeysDescription')}
243
  </p>
244
  <div className='space-y-3'>
245
  {API_PROVIDERS.map(({ id, name }) => (
 
250
  type={visibleKeys[id] ? 'text' : 'password'}
251
  value={apiKeys[id] ?? ''}
252
  onChange={(e) => handleApiKeyChange(id, e.target.value)}
253
+ onBlur={() => flushApiKeySave(id)}
254
  placeholder='Enter API key'
255
  className='border-border bg-card text-foreground placeholder:text-muted-foreground focus:ring-primary w-full rounded-md border px-3 py-1.5 pr-9 text-sm focus:ring-1 focus:outline-none'
256
  />
 
267
  <EyeIcon className='size-4' />
268
  )}
269
  </button>
270
+
271
+ {API_PROVIDERS.find((provider) => provider.id === id)?.free_tier && (
272
+ <span className='text-green-500 text-xs ml-2'>
273
+ {t('settings.freeTier')}
274
+ </span>
275
+ )}
276
  </div>
277
  </div>
278
  ))}
ui/app/providers.tsx CHANGED
@@ -18,6 +18,8 @@ import { api, parseProcessProgress } from '@/lib/api'
18
  import { useDownloadStore } from '@/lib/downloads'
19
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
20
  import { useOperationStore } from '@/lib/stores/operationStore'
 
 
21
 
22
  export function Providers({ children }: { children: ReactNode }) {
23
  const [mounted, setMounted] = useState(false)
@@ -31,6 +33,22 @@ export function Providers({ children }: { children: ReactNode }) {
31
  ensureDownloadSubscribed()
32
  }, [ensureDownloadSubscribed])
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  useEffect(() => {
35
  let unlisten: (() => void) | undefined
36
  ;(async () => {
 
18
  import { useDownloadStore } from '@/lib/downloads'
19
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
20
  import { useOperationStore } from '@/lib/stores/operationStore'
21
+ import { usePreferencesStore } from '@/lib/stores/preferencesStore'
22
+ import { isTauri } from '@/lib/backend'
23
 
24
  export function Providers({ children }: { children: ReactNode }) {
25
  const [mounted, setMounted] = useState(false)
 
33
  ensureDownloadSubscribed()
34
  }, [ensureDownloadSubscribed])
35
 
36
+ useEffect(() => {
37
+ if (!isTauri()) return
38
+ const setApiKey = usePreferencesStore.getState().setApiKey
39
+ void (async () => {
40
+ const providers = ['openai', 'gemini', 'claude']
41
+ for (const provider of providers) {
42
+ try {
43
+ const key = await api.getApiKey(provider)
44
+ setApiKey(provider, key ?? '')
45
+ } catch (error) {
46
+ console.error(`[providers] Failed to load API key for ${provider}`, error)
47
+ }
48
+ }
49
+ })()
50
+ }, [])
51
+
52
  useEffect(() => {
53
  let unlisten: (() => void) | undefined
54
  ;(async () => {
ui/components/canvas/CanvasToolbar.tsx CHANGED
@@ -269,8 +269,7 @@ function LlmStatusPopover() {
269
  </p>
270
  )}
271
 
272
- {/* Language selector — local models only */}
273
- {!isApiModel && activeLanguages.length > 0 && (
274
  <Select
275
  value={llmSelectedLanguage ?? activeLanguages[0]}
276
  onValueChange={llmSetSelectedLanguage}
@@ -288,7 +287,9 @@ function LlmStatusPopover() {
288
  value={language}
289
  data-testid={`llm-language-option-${index}`}
290
  >
291
- {language}
 
 
292
  </SelectItem>
293
  ))}
294
  </SelectContent>
 
269
  </p>
270
  )}
271
 
272
+ {activeLanguages.length > 0 && (
 
273
  <Select
274
  value={llmSelectedLanguage ?? activeLanguages[0]}
275
  onValueChange={llmSetSelectedLanguage}
 
287
  value={language}
288
  data-testid={`llm-language-option-${index}`}
289
  >
290
+ {language.includes('-')
291
+ ? t(`menu.languages.${language}`)
292
+ : language}
293
  </SelectItem>
294
  ))}
295
  </SelectContent>
ui/lib/errors.ts CHANGED
@@ -1,6 +1,7 @@
1
  'use client'
2
 
3
  import { useUiErrorStore } from '@/lib/stores/uiErrorStore'
 
4
 
5
  const SURFACED_RPC_METHODS = new Set([
6
  'open_documents',
@@ -24,13 +25,19 @@ const SURFACED_RPC_METHODS = new Set([
24
  ])
25
 
26
  export const normalizeErrorMessage = (error: unknown) => {
27
- if (error instanceof Error) {
28
- return error.message
29
- }
30
- if (typeof error === 'string') {
31
- return error
 
 
 
 
 
32
  }
33
- return 'Unexpected error'
 
34
  }
35
 
36
  export const reportRpcError = (method: string, error: unknown) => {
 
1
  'use client'
2
 
3
  import { useUiErrorStore } from '@/lib/stores/uiErrorStore'
4
+ import i18n from '@/lib/i18n'
5
 
6
  const SURFACED_RPC_METHODS = new Set([
7
  'open_documents',
 
25
  ])
26
 
27
  export const normalizeErrorMessage = (error: unknown) => {
28
+ const rawMessage =
29
+ error instanceof Error
30
+ ? error.message
31
+ : typeof error === 'string'
32
+ ? error
33
+ : 'Unexpected error'
34
+
35
+ if (rawMessage.startsWith('provider_quota_exceeded:')) {
36
+ const provider = rawMessage.split(':', 2)[1] ?? 'provider'
37
+ return i18n.t('errors.providerQuotaExceeded', { provider })
38
  }
39
+
40
+ return rawMessage
41
  }
42
 
43
  export const reportRpcError = (method: string, error: unknown) => {
ui/lib/query/mutations.ts CHANGED
@@ -49,6 +49,25 @@ const findModelLanguages = (
49
  modelId?: string,
50
  ) => models.find((model) => model.id === modelId)?.languages ?? []
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  const pickLanguage = (
53
  models: { id: string; languages: string[] }[],
54
  modelId?: string,
@@ -437,6 +456,7 @@ export const useDocumentMutations = () => {
437
  try {
438
  const models = getCachedLlmModels(queryClient)
439
  const modelInfo = models.find((m) => m.id === selectedModel)
 
440
  const llmApiKey =
441
  modelInfo && modelInfo.source !== 'local'
442
  ? usePreferencesStore.getState().apiKeys[modelInfo.source]
@@ -445,7 +465,7 @@ export const useDocumentMutations = () => {
445
  index: resolvedIndex,
446
  llmModelId: selectedModel,
447
  llmApiKey,
448
- language: selectedLanguage,
449
  shaderEffect: renderEffect,
450
  shaderStroke: renderStroke,
451
  fontFamily,
@@ -475,6 +495,7 @@ export const useDocumentMutations = () => {
475
  try {
476
  const models = getCachedLlmModels(queryClient)
477
  const modelInfo = models.find((m) => m.id === selectedModel)
 
478
  const llmApiKey =
479
  modelInfo && modelInfo.source !== 'local'
480
  ? usePreferencesStore.getState().apiKeys[modelInfo.source]
@@ -482,7 +503,7 @@ export const useDocumentMutations = () => {
482
  await api.process({
483
  llmModelId: selectedModel,
484
  llmApiKey,
485
- language: selectedLanguage,
486
  shaderEffect: renderEffect,
487
  shaderStroke: renderStroke,
488
  fontFamily,
@@ -639,7 +660,11 @@ export const useLlmMutations = () => {
639
  : languages[0]
640
  : undefined
641
 
642
- await api.llmGenerate(resolvedIndex, textBlockIndex, language)
 
 
 
 
643
  await invalidateCurrentDocument(queryClient, resolvedIndex)
644
  useEditorUiStore.getState().setShowTextBlocksOverlay(true)
645
  if (typeof textBlockIndex === 'number') {
 
49
  modelId?: string,
50
  ) => models.find((model) => model.id === modelId)?.languages ?? []
51
 
52
+ const apiLanguageToBackendName = (language?: string) => {
53
+ switch (language) {
54
+ case 'en-US':
55
+ return 'English'
56
+ case 'zh-CN':
57
+ return '简体中文'
58
+ case 'zh-TW':
59
+ return '繁體中文'
60
+ case 'ja-JP':
61
+ return '日本語'
62
+ case 'ru-RU':
63
+ return 'Русский'
64
+ case 'es-ES':
65
+ return 'Español'
66
+ default:
67
+ return language
68
+ }
69
+ }
70
+
71
  const pickLanguage = (
72
  models: { id: string; languages: string[] }[],
73
  modelId?: string,
 
456
  try {
457
  const models = getCachedLlmModels(queryClient)
458
  const modelInfo = models.find((m) => m.id === selectedModel)
459
+ const language = apiLanguageToBackendName(selectedLanguage)
460
  const llmApiKey =
461
  modelInfo && modelInfo.source !== 'local'
462
  ? usePreferencesStore.getState().apiKeys[modelInfo.source]
 
465
  index: resolvedIndex,
466
  llmModelId: selectedModel,
467
  llmApiKey,
468
+ language,
469
  shaderEffect: renderEffect,
470
  shaderStroke: renderStroke,
471
  fontFamily,
 
495
  try {
496
  const models = getCachedLlmModels(queryClient)
497
  const modelInfo = models.find((m) => m.id === selectedModel)
498
+ const language = apiLanguageToBackendName(selectedLanguage)
499
  const llmApiKey =
500
  modelInfo && modelInfo.source !== 'local'
501
  ? usePreferencesStore.getState().apiKeys[modelInfo.source]
 
503
  await api.process({
504
  llmModelId: selectedModel,
505
  llmApiKey,
506
+ language,
507
  shaderEffect: renderEffect,
508
  shaderStroke: renderStroke,
509
  fontFamily,
 
660
  : languages[0]
661
  : undefined
662
 
663
+ await api.llmGenerate(
664
+ resolvedIndex,
665
+ textBlockIndex,
666
+ apiLanguageToBackendName(language),
667
+ )
668
  await invalidateCurrentDocument(queryClient, resolvedIndex)
669
  useEditorUiStore.getState().setShowTextBlocksOverlay(true)
670
  if (typeof textBlockIndex === 'number') {
ui/public/locales/en-US/translation.json CHANGED
@@ -159,7 +159,8 @@
159
  },
160
  "errors": {
161
  "title": "Error",
162
- "dismiss": "Dismiss"
 
163
  },
164
  "settings": {
165
  "title": "Settings",
@@ -182,6 +183,9 @@
182
  "aboutRepository": "Repository",
183
  "device": "Device",
184
  "deviceDescription": "Hardware acceleration information.",
185
- "deviceMl": "ML Compute"
 
 
 
186
  }
187
  }
 
159
  },
160
  "errors": {
161
  "title": "Error",
162
+ "dismiss": "Dismiss",
163
+ "providerQuotaExceeded": "{{provider}} API quota exceeded. Check your billing or wait for your quota to reset."
164
  },
165
  "settings": {
166
  "title": "Settings",
 
183
  "aboutRepository": "Repository",
184
  "device": "Device",
185
  "deviceDescription": "Hardware acceleration information.",
186
+ "deviceMl": "ML Compute",
187
+ "apiKeys": "API Keys",
188
+ "apiKeysDescription": "Manage your API keys for different providers. Your keys are stored securely on your device keychain and are never shared with anyone.",
189
+ "freeTier": "Free Tier"
190
  }
191
  }
ui/public/locales/es-ES/translation.json CHANGED
@@ -159,7 +159,8 @@
159
  },
160
  "errors": {
161
  "title": "Error",
162
- "dismiss": "Cerrar"
 
163
  },
164
  "settings": {
165
  "title": "Configuración",
@@ -182,6 +183,9 @@
182
  "aboutRepository": "Repositorio",
183
  "device": "Dispositivo",
184
  "deviceDescription": "Información sobre aceleración por hardware.",
185
- "deviceMl": "Cómputo ML"
 
 
 
186
  }
187
  }
 
159
  },
160
  "errors": {
161
  "title": "Error",
162
+ "dismiss": "Cerrar",
163
+ "providerQuotaExceeded": "Se agotó la cuota de API de {{provider}}. Revisa la facturación o espera a que se restablezca la cuota."
164
  },
165
  "settings": {
166
  "title": "Configuración",
 
183
  "aboutRepository": "Repositorio",
184
  "device": "Dispositivo",
185
  "deviceDescription": "Información sobre aceleración por hardware.",
186
+ "deviceMl": "Cómputo ML",
187
+ "apiKeys": "Claves API",
188
+ "apiKeysDescription": "Gestiona tus claves API para diferentes proveedores. Tus claves se almacenan de forma segura en el llavero de tu dispositivo y nunca se comparten con nadie.",
189
+ "freeTier": "Nivel gratuito"
190
  }
191
  }
ui/public/locales/ja-JP/translation.json CHANGED
@@ -159,7 +159,8 @@
159
  },
160
  "errors": {
161
  "title": "エラー",
162
- "dismiss": "閉じる"
 
163
  },
164
  "settings": {
165
  "title": "設定",
@@ -182,6 +183,9 @@
182
  "aboutRepository": "リポジトリ",
183
  "device": "デバイス",
184
  "deviceDescription": "ハードウェアアクセラレーション情報",
185
- "deviceMl": "ML 演算"
 
 
 
186
  }
187
  }
 
159
  },
160
  "errors": {
161
  "title": "エラー",
162
+ "dismiss": "閉じる",
163
+ "providerQuotaExceeded": "{{provider}} の API クォータを超過しました。課金状況を確認するか、クォータのリセットを待ってください。"
164
  },
165
  "settings": {
166
  "title": "設定",
 
183
  "aboutRepository": "リポジトリ",
184
  "device": "デバイス",
185
  "deviceDescription": "ハードウェアアクセラレーション情報",
186
+ "deviceMl": "ML 演算",
187
+ "apiKeys": "API キー",
188
+ "apiKeysDescription": "異なるプロバイダーの API キーを管理します。キーはデバイスのキーチェーンに安全に保存され、他人と共有されることはありません。",
189
+ "freeTier": "無料プラン"
190
  }
191
  }
ui/public/locales/ru-RU/translation.json CHANGED
@@ -159,7 +159,8 @@
159
  },
160
  "errors": {
161
  "title": "Ошибка",
162
- "dismiss": "Закрыть"
 
163
  },
164
  "settings": {
165
  "title": "Настройки",
@@ -182,6 +183,9 @@
182
  "aboutRepository": "Репозиторий",
183
  "device": "Устройство",
184
  "deviceDescription": "Информация об аппаратном ускорении.",
185
- "deviceMl": "ML Compute"
 
 
 
186
  }
187
  }
 
159
  },
160
  "errors": {
161
  "title": "Ошибка",
162
+ "dismiss": "Закрыть",
163
+ "providerQuotaExceeded": "Квота API для {{provider}} исчерпана. Проверьте биллинг или дождитесь сброса квоты."
164
  },
165
  "settings": {
166
  "title": "Настройки",
 
183
  "aboutRepository": "Репозиторий",
184
  "device": "Устройство",
185
  "deviceDescription": "Информация об аппаратном ускорении.",
186
+ "deviceMl": "ML Compute",
187
+ "apiKeys": "API ключи",
188
+ "apiKeysDescription": "Управляйте своими API ключами для различных провайдеров. Ваши ключи надежно хранятся в связке ключей вашего устройства и никогда не передаются другим лицам.",
189
+ "freeTier": "Бесплатный уровень"
190
  }
191
  }
ui/public/locales/zh-CN/translation.json CHANGED
@@ -159,7 +159,8 @@
159
  },
160
  "errors": {
161
  "title": "错误",
162
- "dismiss": "关闭"
 
163
  },
164
  "settings": {
165
  "title": "设置",
@@ -182,6 +183,9 @@
182
  "aboutRepository": "代码仓库",
183
  "device": "设备",
184
  "deviceDescription": "硬件加速信息",
185
- "deviceMl": "ML 计算"
 
 
 
186
  }
187
  }
 
159
  },
160
  "errors": {
161
  "title": "错误",
162
+ "dismiss": "关闭",
163
+ "providerQuotaExceeded": "{{provider}} API 配额已用尽。请检查计费状态或等待配额重置。"
164
  },
165
  "settings": {
166
  "title": "设置",
 
183
  "aboutRepository": "代码仓库",
184
  "device": "设备",
185
  "deviceDescription": "硬件加速信息",
186
+ "deviceMl": "ML 计算",
187
+ "apiKeys": "API 密钥",
188
+ "apiKeysDescription": "管理您在不同提供商的 API 密钥。您的密钥会安全地存储在设备的密钥链中,绝不会与他人共享。",
189
+ "freeTier": "免费等级"
190
  }
191
  }
ui/public/locales/zh-TW/translation.json CHANGED
@@ -159,7 +159,8 @@
159
  },
160
  "errors": {
161
  "title": "錯誤",
162
- "dismiss": "關閉"
 
163
  },
164
  "settings": {
165
  "title": "設定",
@@ -182,6 +183,9 @@
182
  "aboutRepository": "原始碼",
183
  "device": "裝置",
184
  "deviceDescription": "硬體加速資訊",
185
- "deviceMl": "ML 運算"
 
 
 
186
  }
187
  }
 
159
  },
160
  "errors": {
161
  "title": "錯誤",
162
+ "dismiss": "關閉",
163
+ "providerQuotaExceeded": "{{provider}} API 配額已用盡。請檢查計費狀態或等待配額重置。"
164
  },
165
  "settings": {
166
  "title": "設定",
 
183
  "aboutRepository": "原始碼",
184
  "device": "裝置",
185
  "deviceDescription": "硬體加速資訊",
186
+ "deviceMl": "ML 運算",
187
+ "apiKeys": "API 密鑰",
188
+ "apiKeysDescription": "管理您在不同提供商的 API 密鑰。您的密鑰會安全地存儲在設備的密鑰鏈中,絕不會與他人共享。",
189
+ "freeTier": "免費等級"
190
  }
191
  }