android-localisation 1.5.0__tar.gz → 1.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {android_localisation-1.5.0/android_localisation.egg-info → android_localisation-1.6.2}/PKG-INFO +79 -12
  2. {android_localisation-1.5.0 → android_localisation-1.6.2}/README.md +78 -11
  3. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation/__init__.py +1 -1
  4. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation/cli.py +27 -3
  5. android_localisation-1.6.2/android_localisation/credentials.py +185 -0
  6. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation/store_listing.py +4 -5
  7. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation/translate.py +43 -28
  8. {android_localisation-1.5.0 → android_localisation-1.6.2/android_localisation.egg-info}/PKG-INFO +79 -12
  9. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation.egg-info/SOURCES.txt +1 -0
  10. {android_localisation-1.5.0 → android_localisation-1.6.2}/pyproject.toml +1 -1
  11. {android_localisation-1.5.0 → android_localisation-1.6.2}/LICENSE +0 -0
  12. {android_localisation-1.5.0 → android_localisation-1.6.2}/MANIFEST.in +0 -0
  13. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation/__main__.py +0 -0
  14. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation/fix.py +0 -0
  15. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation/java/VerifyStrings.java +0 -0
  16. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation/locales.py +0 -0
  17. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation/resources.py +0 -0
  18. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation/setup_path.py +0 -0
  19. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation/updates.py +0 -0
  20. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation/verify.py +0 -0
  21. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation.egg-info/dependency_links.txt +0 -0
  22. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation.egg-info/entry_points.txt +0 -0
  23. {android_localisation-1.5.0 → android_localisation-1.6.2}/android_localisation.egg-info/top_level.txt +0 -0
  24. {android_localisation-1.5.0 → android_localisation-1.6.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: android-localisation
3
- Version: 1.5.0
3
+ Version: 1.6.2
4
4
  Summary: Zero-dependency Android strings.xml and Google Play store listing translation using LLMs (Gemini, OpenAI, Anthropic, Ollama).
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/BharathKmalviya/android-llm-localization
@@ -127,12 +127,16 @@ When you run `android-localise translate --api-key YOUR_KEY`, here's exactly wha
127
127
  4. Parses the response and checks duplicate/unexpected/missing resources, attributes, inline markup, item structure, control escapes and format arguments. A valid result replaces the file atomically; new folders are created only when saving. `--skip-existing` validates existing files and skips them without API calls. `--dry-run` shows a diff without writing any files or creating folders
128
128
  5. Waits 5 seconds between each language request to avoid hitting API rate limits
129
129
 
130
+ Keys resolve from `--api-key`, then provider environment variables / `API_KEY`,
131
+ then saved Windows credentials for built-in provider endpoints. The CLI never
132
+ prompts for a key during translation; use `credentials set` yourself beforehand.
133
+
130
134
  **Defaults used when you don't specify anything:**
131
135
 
132
136
  | What | Default |
133
137
  |---|---|
134
138
  | Provider | Gemini |
135
- | Model | `gemini-3.8-flash` |
139
+ | Model | `gemini-3.5-flash-lite` |
136
140
  | Source directory | `app/src/main/res` |
137
141
  | Delay between requests | 5 seconds |
138
142
  | App context | none (generic prompt) |
@@ -226,7 +230,7 @@ android-localise translate \
226
230
 
227
231
  | Flag | What it does | Default |
228
232
  |---|---|---|
229
- | `--api-key` | Your API key | reads from env var |
233
+ | `--api-key` | Explicit key override; omit for environment or saved Windows credentials | environment, then saved Windows key |
230
234
  | `--provider` | Which AI to use: `gemini` `openai` `anthropic` `custom` | `gemini` |
231
235
  | `--model` | Specific model to use | see [Providers](#providers) |
232
236
  | `--languages` | Comma-separated Android/conventional codes and/or `all`, e.g. `all,zu` or `hi,es-ES,b+es+419` | scan existing destination locale folders |
@@ -305,7 +309,7 @@ Console import file. The CLI does not upload or publish a listing.
305
309
  | `--dry-run` | Generate, validate and display diffs without files/directories being written | off; API usage applies |
306
310
  | `--provider` | `gemini`, `openai`, `anthropic`, `custom` | `gemini` |
307
311
  | `--model` | Pin any supported model and disable fallbacks | provider default |
308
- | `--api-key` | Key, or provider-specific environment variable / `API_KEY` | environment |
312
+ | `--api-key` | Explicit key override; omit for environment or saved Windows credentials | environment, then saved Windows key |
309
313
  | `--base-url` | OpenAI-compatible endpoint; required for `custom` | provider endpoint |
310
314
  | `--app-context` | Terminology context; source copy remains the source of facts | none |
311
315
  | `--sleep` | Delay between requests, including correction/fallback requests | `5.0` seconds |
@@ -480,18 +484,18 @@ environment behavior. All commands can also run through `python -m android_local
480
484
 
481
485
  ## Providers
482
486
 
483
- By default the tool uses Gemini with `gemini-3.8-flash`. You can switch providers with `--provider` and optionally pin a specific model with `--model`. Configured defaults and fallbacks use only the latest general-purpose text-model lineup, with defaults favoring speed and cost within that lineup.
487
+ By default the tool uses Gemini 3.5 Flash-Lite (`gemini-3.5-flash-lite`) for both XML and store listing translation. You can switch providers with `--provider` and optionally pin a specific model with `--model`.
484
488
 
485
- | Provider | Default model | Fallbacks | API key env var |
489
+ | Provider | Default model | Fallback | API key env var |
486
490
  |---|---|---|---|
487
- | `gemini` _(default)_ | `gemini-3.8-flash` | none | `GEMINI_API_KEY` |
488
- | `openai` | `gpt-6-luna` | `gpt-6.1-sol` → `gpt-6-astra` | `OPENAI_API_KEY` |
489
- | `anthropic` | `claude-sonnet-5-5` | `claude-opus-5-5` | `ANTHROPIC_API_KEY` |
491
+ | `gemini` _(default)_ | `gemini-3.5-flash-lite` | `gemini-3.5-flash` | `GEMINI_API_KEY` |
492
+ | `openai` | `gpt-6-luna` | `gpt-5.6-terra` | `OPENAI_API_KEY` |
493
+ | `anthropic` | `claude-haiku-4-5` | `claude-sonnet-5-5` | `ANTHROPIC_API_KEY` |
490
494
  | `custom` | set with `--model` | none | `OPENAI_API_KEY` (optional) |
491
495
 
492
- If the default model returns a "model not found" error (e.g. it was deprecated), the tool automatically retries with the next fallback. If you pin a model with `--model`, no fallback is used.
496
+ Each hosted provider has exactly one automatic fallback. If the default model returns a "model not found" error (e.g. it was deprecated), the tool retries with that fallback. Authentication, quota and network errors do not trigger a model fallback. If you pin a model with `--model`, no fallback is used.
493
497
 
494
- Model IDs and compatibility checked against [Google's model catalog](https://ai.google.dev/gemini-api/docs/models), [OpenAI's model catalog](https://developers.openai.com/api/docs/models) and [Anthropic's model catalog](https://platform.claude.com/docs/en/models/overview) on **2026-10-02**. Older Gemini, GPT-5 and Haiku 4.5 models are excluded from automatic selection. Gemini has no fallback in its latest stable text generation. OpenAI and Anthropic fallbacks are higher-cost models; pin `--model` to avoid automatic tier changes. Explicit model selection and custom/local endpoints remain available. The catalog is bundled with each CLI release, rather than automatically discovering models at runtime.
498
+ These default/fallback pairs follow the selected configuration. Gemini model IDs were checked against [Flash-Lite](https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash-lite) and [Flash](https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash) documentation; the OpenAI fallback against [GPT-5.6 Terra](https://developers.openai.com/api/docs/models/gpt-5.6-terra); and Anthropic's default/fallback against its [model migration guide](https://platform.claude.com/docs/en/models/sonnet-5-5/migration-guide) on **2026-10-05**. The existing OpenAI default was checked on **2026-10-02**. Pin `--model` to avoid automatic model changes. Explicit model selection and custom/local endpoints remain available. The catalog is bundled with each CLI release, rather than automatically discovering models at runtime.
495
499
 
496
500
  OpenAI and local providers retain the Chat Completions request format. Provider replies indicating truncation or blocked/incomplete output are rejected. Anthropic allows up to 16,384 output tokens per request and text blocks are collected separately from thinking blocks. Large files can still exceed a model's limits; automatic batching is future work.
497
501
 
@@ -525,9 +529,72 @@ android-localise translate \
525
529
 
526
530
  ---
527
531
 
532
+ ## Save a key once on Windows
533
+
534
+ Run this yourself in your terminal:
535
+
536
+ ```bash
537
+ android-localise credentials set --provider gemini
538
+ ```
539
+
540
+ Enter your key at the hidden prompt. It is stored in **Windows Credential
541
+ Manager**, for your Windows user on this computer, and persists across terminal
542
+ and computer restarts. The CLI does not write a plaintext key file or modify
543
+ environment variables. Running `set` again replaces that provider's saved key.
544
+ There is no key argument or piped-input option for this command; it requires an
545
+ interactive terminal and refuses a prompt that cannot hide input.
546
+
547
+ Then you or an AI assistant can run ordinary commands without including a key:
548
+
549
+ ```bash
550
+ android-localise translate --languages hi,es
551
+ android-localise store-listing --source listing.json --languages hi-IN,es-ES
552
+ android-localise credentials status --provider gemini
553
+ android-localise credentials remove --provider gemini
554
+ ```
555
+
556
+ `status` reports only `saved` or `not saved`; no command displays the saved value.
557
+ `remove` deletes only this CLI's saved entry for that provider; it does not revoke
558
+ the provider key or clear environment-variable overrides. The same commands also
559
+ work with `python -m android_localisation credentials ...`.
560
+
561
+ | Argument | Description | Default |
562
+ |---|---|---|
563
+ | `set`, `status`, `remove` | Hidden entry, presence check, or deletion | required action |
564
+ | `--provider` | `gemini`, `openai` or `anthropic` | `gemini` |
565
+
566
+ Both translation commands use this precedence: `--api-key` → provider-specific
567
+ environment variable → `API_KEY` → saved Windows key. Existing overrides retain
568
+ their behavior; if an old environment key is set, saving a new key does not
569
+ override it. Saved OpenAI keys load only for its default endpoint; `custom`
570
+ providers and other `--base-url` endpoints continue using explicit keys or
571
+ environment variables. Saved keys are not automatically sent to custom hosts.
572
+ Provider requests reject HTTP redirects, so use a custom endpoint's final URL.
573
+ Gemini authentication uses a header rather than a URL query parameter, and
574
+ API/network error messages redact the key used for that request before display.
575
+
576
+ This keeps keys out of chat, command arguments and routine CLI output. It
577
+ **does not isolate secrets from an AI or other program with unrestricted access
578
+ under your Windows account**: Windows permits programs running as that user to
579
+ read their credentials. The key is also present in memory during an API request.
580
+ For stronger separation, use a restricted runner or separately secured proxy.
581
+ See [Microsoft's credential API documentation](https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credreadw).
582
+
583
+ Saved-key commands are Windows-only, with no plaintext fallback. Other platforms
584
+ keep using environment variables or `--api-key`.
585
+
586
+ Manual check: save a key yourself, confirm input is hidden, open a new terminal
587
+ and check `status`. Run a small XML and listing preview without `--api-key`,
588
+ review the output, then remove the entry and confirm `not saved`. Ensure any key
589
+ environment overrides are absent when checking saved-key behavior.
590
+
591
+ ---
592
+
528
593
  ## Environment variables
529
594
 
530
- Set your API key as an env variable so you don't have to pass it every time:
595
+ Environment variables remain available on all platforms. For Windows saved
596
+ credentials, use the commands above instead of putting a key in shell history.
597
+ To use an environment variable:
531
598
 
532
599
  ```bash
533
600
  # macOS / Linux
@@ -99,12 +99,16 @@ When you run `android-localise translate --api-key YOUR_KEY`, here's exactly wha
99
99
  4. Parses the response and checks duplicate/unexpected/missing resources, attributes, inline markup, item structure, control escapes and format arguments. A valid result replaces the file atomically; new folders are created only when saving. `--skip-existing` validates existing files and skips them without API calls. `--dry-run` shows a diff without writing any files or creating folders
100
100
  5. Waits 5 seconds between each language request to avoid hitting API rate limits
101
101
 
102
+ Keys resolve from `--api-key`, then provider environment variables / `API_KEY`,
103
+ then saved Windows credentials for built-in provider endpoints. The CLI never
104
+ prompts for a key during translation; use `credentials set` yourself beforehand.
105
+
102
106
  **Defaults used when you don't specify anything:**
103
107
 
104
108
  | What | Default |
105
109
  |---|---|
106
110
  | Provider | Gemini |
107
- | Model | `gemini-3.8-flash` |
111
+ | Model | `gemini-3.5-flash-lite` |
108
112
  | Source directory | `app/src/main/res` |
109
113
  | Delay between requests | 5 seconds |
110
114
  | App context | none (generic prompt) |
@@ -198,7 +202,7 @@ android-localise translate \
198
202
 
199
203
  | Flag | What it does | Default |
200
204
  |---|---|---|
201
- | `--api-key` | Your API key | reads from env var |
205
+ | `--api-key` | Explicit key override; omit for environment or saved Windows credentials | environment, then saved Windows key |
202
206
  | `--provider` | Which AI to use: `gemini` `openai` `anthropic` `custom` | `gemini` |
203
207
  | `--model` | Specific model to use | see [Providers](#providers) |
204
208
  | `--languages` | Comma-separated Android/conventional codes and/or `all`, e.g. `all,zu` or `hi,es-ES,b+es+419` | scan existing destination locale folders |
@@ -277,7 +281,7 @@ Console import file. The CLI does not upload or publish a listing.
277
281
  | `--dry-run` | Generate, validate and display diffs without files/directories being written | off; API usage applies |
278
282
  | `--provider` | `gemini`, `openai`, `anthropic`, `custom` | `gemini` |
279
283
  | `--model` | Pin any supported model and disable fallbacks | provider default |
280
- | `--api-key` | Key, or provider-specific environment variable / `API_KEY` | environment |
284
+ | `--api-key` | Explicit key override; omit for environment or saved Windows credentials | environment, then saved Windows key |
281
285
  | `--base-url` | OpenAI-compatible endpoint; required for `custom` | provider endpoint |
282
286
  | `--app-context` | Terminology context; source copy remains the source of facts | none |
283
287
  | `--sleep` | Delay between requests, including correction/fallback requests | `5.0` seconds |
@@ -452,18 +456,18 @@ environment behavior. All commands can also run through `python -m android_local
452
456
 
453
457
  ## Providers
454
458
 
455
- By default the tool uses Gemini with `gemini-3.8-flash`. You can switch providers with `--provider` and optionally pin a specific model with `--model`. Configured defaults and fallbacks use only the latest general-purpose text-model lineup, with defaults favoring speed and cost within that lineup.
459
+ By default the tool uses Gemini 3.5 Flash-Lite (`gemini-3.5-flash-lite`) for both XML and store listing translation. You can switch providers with `--provider` and optionally pin a specific model with `--model`.
456
460
 
457
- | Provider | Default model | Fallbacks | API key env var |
461
+ | Provider | Default model | Fallback | API key env var |
458
462
  |---|---|---|---|
459
- | `gemini` _(default)_ | `gemini-3.8-flash` | none | `GEMINI_API_KEY` |
460
- | `openai` | `gpt-6-luna` | `gpt-6.1-sol` → `gpt-6-astra` | `OPENAI_API_KEY` |
461
- | `anthropic` | `claude-sonnet-5-5` | `claude-opus-5-5` | `ANTHROPIC_API_KEY` |
463
+ | `gemini` _(default)_ | `gemini-3.5-flash-lite` | `gemini-3.5-flash` | `GEMINI_API_KEY` |
464
+ | `openai` | `gpt-6-luna` | `gpt-5.6-terra` | `OPENAI_API_KEY` |
465
+ | `anthropic` | `claude-haiku-4-5` | `claude-sonnet-5-5` | `ANTHROPIC_API_KEY` |
462
466
  | `custom` | set with `--model` | none | `OPENAI_API_KEY` (optional) |
463
467
 
464
- If the default model returns a "model not found" error (e.g. it was deprecated), the tool automatically retries with the next fallback. If you pin a model with `--model`, no fallback is used.
468
+ Each hosted provider has exactly one automatic fallback. If the default model returns a "model not found" error (e.g. it was deprecated), the tool retries with that fallback. Authentication, quota and network errors do not trigger a model fallback. If you pin a model with `--model`, no fallback is used.
465
469
 
466
- Model IDs and compatibility checked against [Google's model catalog](https://ai.google.dev/gemini-api/docs/models), [OpenAI's model catalog](https://developers.openai.com/api/docs/models) and [Anthropic's model catalog](https://platform.claude.com/docs/en/models/overview) on **2026-10-02**. Older Gemini, GPT-5 and Haiku 4.5 models are excluded from automatic selection. Gemini has no fallback in its latest stable text generation. OpenAI and Anthropic fallbacks are higher-cost models; pin `--model` to avoid automatic tier changes. Explicit model selection and custom/local endpoints remain available. The catalog is bundled with each CLI release, rather than automatically discovering models at runtime.
470
+ These default/fallback pairs follow the selected configuration. Gemini model IDs were checked against [Flash-Lite](https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash-lite) and [Flash](https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash) documentation; the OpenAI fallback against [GPT-5.6 Terra](https://developers.openai.com/api/docs/models/gpt-5.6-terra); and Anthropic's default/fallback against its [model migration guide](https://platform.claude.com/docs/en/models/sonnet-5-5/migration-guide) on **2026-10-05**. The existing OpenAI default was checked on **2026-10-02**. Pin `--model` to avoid automatic model changes. Explicit model selection and custom/local endpoints remain available. The catalog is bundled with each CLI release, rather than automatically discovering models at runtime.
467
471
 
468
472
  OpenAI and local providers retain the Chat Completions request format. Provider replies indicating truncation or blocked/incomplete output are rejected. Anthropic allows up to 16,384 output tokens per request and text blocks are collected separately from thinking blocks. Large files can still exceed a model's limits; automatic batching is future work.
469
473
 
@@ -497,9 +501,72 @@ android-localise translate \
497
501
 
498
502
  ---
499
503
 
504
+ ## Save a key once on Windows
505
+
506
+ Run this yourself in your terminal:
507
+
508
+ ```bash
509
+ android-localise credentials set --provider gemini
510
+ ```
511
+
512
+ Enter your key at the hidden prompt. It is stored in **Windows Credential
513
+ Manager**, for your Windows user on this computer, and persists across terminal
514
+ and computer restarts. The CLI does not write a plaintext key file or modify
515
+ environment variables. Running `set` again replaces that provider's saved key.
516
+ There is no key argument or piped-input option for this command; it requires an
517
+ interactive terminal and refuses a prompt that cannot hide input.
518
+
519
+ Then you or an AI assistant can run ordinary commands without including a key:
520
+
521
+ ```bash
522
+ android-localise translate --languages hi,es
523
+ android-localise store-listing --source listing.json --languages hi-IN,es-ES
524
+ android-localise credentials status --provider gemini
525
+ android-localise credentials remove --provider gemini
526
+ ```
527
+
528
+ `status` reports only `saved` or `not saved`; no command displays the saved value.
529
+ `remove` deletes only this CLI's saved entry for that provider; it does not revoke
530
+ the provider key or clear environment-variable overrides. The same commands also
531
+ work with `python -m android_localisation credentials ...`.
532
+
533
+ | Argument | Description | Default |
534
+ |---|---|---|
535
+ | `set`, `status`, `remove` | Hidden entry, presence check, or deletion | required action |
536
+ | `--provider` | `gemini`, `openai` or `anthropic` | `gemini` |
537
+
538
+ Both translation commands use this precedence: `--api-key` → provider-specific
539
+ environment variable → `API_KEY` → saved Windows key. Existing overrides retain
540
+ their behavior; if an old environment key is set, saving a new key does not
541
+ override it. Saved OpenAI keys load only for its default endpoint; `custom`
542
+ providers and other `--base-url` endpoints continue using explicit keys or
543
+ environment variables. Saved keys are not automatically sent to custom hosts.
544
+ Provider requests reject HTTP redirects, so use a custom endpoint's final URL.
545
+ Gemini authentication uses a header rather than a URL query parameter, and
546
+ API/network error messages redact the key used for that request before display.
547
+
548
+ This keeps keys out of chat, command arguments and routine CLI output. It
549
+ **does not isolate secrets from an AI or other program with unrestricted access
550
+ under your Windows account**: Windows permits programs running as that user to
551
+ read their credentials. The key is also present in memory during an API request.
552
+ For stronger separation, use a restricted runner or separately secured proxy.
553
+ See [Microsoft's credential API documentation](https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credreadw).
554
+
555
+ Saved-key commands are Windows-only, with no plaintext fallback. Other platforms
556
+ keep using environment variables or `--api-key`.
557
+
558
+ Manual check: save a key yourself, confirm input is hidden, open a new terminal
559
+ and check `status`. Run a small XML and listing preview without `--api-key`,
560
+ review the output, then remove the entry and confirm `not saved`. Ensure any key
561
+ environment overrides are absent when checking saved-key behavior.
562
+
563
+ ---
564
+
500
565
  ## Environment variables
501
566
 
502
- Set your API key as an env variable so you don't have to pass it every time:
567
+ Environment variables remain available on all platforms. For Windows saved
568
+ credentials, use the commands above instead of putting a key in shell history.
569
+ To use an environment variable:
503
570
 
504
571
  ```bash
505
572
  # macOS / Linux
@@ -2,4 +2,4 @@
2
2
  android-localisation: Zero-dependency Android XML and Play listing translation using LLMs.
3
3
  """
4
4
 
5
- __version__ = "1.5.0"
5
+ __version__ = "1.6.2"
@@ -33,11 +33,12 @@ Other examples:
33
33
  android-localise store-listing --source listing.json --languages hi,es-ES
34
34
  android-localise translate --languages hi --missing-only --dry-run
35
35
  android-localise models --provider openai
36
+ android-localise credentials set --provider gemini (Windows, hidden prompt)
36
37
  python -m android_localisation setup-path (Windows, one-time)
37
38
 
38
39
  Use android-localise COMMAND --help for flags, defaults and examples.
39
40
  All commands also work with: python -m android_localisation COMMAND
40
- Keys: GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY, or API_KEY.
41
+ Keys: --api-key, environment variables, then saved Windows credentials.
41
42
  Update notices: set ANDROID_LOCALISE_NO_UPDATE_CHECK=1 to disable them.""",
42
43
  )
43
44
  parser.add_argument("--version", action="version", version=f"android-localisation {__version__}")
@@ -71,7 +72,7 @@ Use android-localise models to see current defaults and fallbacks.""",
71
72
  add_flexible_arguments(translate_parser)
72
73
  translate_parser.add_argument("--provider", choices=["gemini", "openai", "anthropic", "custom"], default="gemini", help="AI provider (default: gemini)")
73
74
  translate_parser.add_argument("--model", help="Pin any supported model and disable fallbacks (default: provider default; see models)")
74
- translate_parser.add_argument("--api-key", help="API key, or set GEMINI_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / API_KEY")
75
+ translate_parser.add_argument("--api-key", help="API key; otherwise use provider environment variable / API_KEY, then saved Windows key")
75
76
  translate_parser.add_argument("--base-url", help="Custom OpenAI-compatible endpoint URL (required for 'custom' provider)")
76
77
  translate_parser.add_argument("--app-context", help="Short description of your app for better translations")
77
78
  translate_parser.add_argument("--sleep", type=float, default=5.0, help="Seconds between API requests (default: 5.0)")
@@ -151,6 +152,25 @@ Custom/local providers require an explicit --model.""",
151
152
  models_parser.add_argument("--provider", choices=["gemini", "openai", "anthropic"], default=None,
152
153
  help="Filter by provider (shows all if not set)")
153
154
 
155
+ from android_localisation.credentials import add_arguments as add_credential_arguments
156
+ credentials_parser = subparsers.add_parser(
157
+ "credentials", help="Manage saved Windows API keys without displaying them",
158
+ description="""Save provider keys in Windows Credential Manager for your user account.
159
+ Run set yourself in an interactive terminal; the key prompt is hidden.
160
+ Status reports presence only. Translation commands load saved keys automatically
161
+ after argument/environment overrides. This does not isolate keys from programs
162
+ or AI agents with unrestricted access under your Windows account.""",
163
+ formatter_class=argparse.RawDescriptionHelpFormatter,
164
+ epilog="""Examples:
165
+ android-localise credentials set --provider gemini
166
+ android-localise credentials status --provider gemini
167
+ android-localise credentials remove --provider gemini
168
+
169
+ No key argument, piped input or plaintext storage fallback is supported.
170
+ Custom endpoints continue using explicit keys or environment variables.""",
171
+ )
172
+ add_credential_arguments(credentials_parser)
173
+
154
174
  subparsers.add_parser(
155
175
  "setup-path", help="Add the installed Scripts folder to Windows user PATH",
156
176
  description="""One-time Windows setup for an installed CLI that PowerShell cannot find.
@@ -183,7 +203,11 @@ Exit code 1 reports unsupported platforms, virtual environments or setup failure
183
203
 
184
204
 
185
205
  def _run_command(args):
186
- if args.command == "setup-path":
206
+ if args.command == "credentials":
207
+ from android_localisation.credentials import main as run
208
+ return run(args)
209
+
210
+ elif args.command == "setup-path":
187
211
  from android_localisation.setup_path import main as run
188
212
  return run(args)
189
213
 
@@ -0,0 +1,185 @@
1
+ """Explicit, user-scoped API key storage in Windows Credential Manager."""
2
+
3
+ import argparse
4
+ import ctypes
5
+ import getpass
6
+ import os
7
+ import sys
8
+ import warnings
9
+ from ctypes import wintypes
10
+
11
+
12
+ PROVIDERS = ("gemini", "openai", "anthropic")
13
+ OPENAI_ENDPOINT = "https://api.openai.com/v1/chat/completions"
14
+ _GENERIC = 1
15
+ _LOCAL_MACHINE = 2 # Persistent on this computer, still scoped to the user.
16
+ _NOT_FOUND = 1168
17
+ _MAX_BLOB = 2560
18
+
19
+
20
+ class _Credential(ctypes.Structure):
21
+ _fields_ = [
22
+ ("Flags", wintypes.DWORD), ("Type", wintypes.DWORD),
23
+ ("TargetName", wintypes.LPWSTR), ("Comment", wintypes.LPWSTR),
24
+ ("LastWritten", wintypes.FILETIME), ("CredentialBlobSize", wintypes.DWORD),
25
+ ("CredentialBlob", ctypes.POINTER(wintypes.BYTE)), ("Persist", wintypes.DWORD),
26
+ ("AttributeCount", wintypes.DWORD), ("Attributes", ctypes.c_void_p),
27
+ ("TargetAlias", wintypes.LPWSTR), ("UserName", wintypes.LPWSTR),
28
+ ]
29
+
30
+
31
+ def _target(provider):
32
+ if provider not in PROVIDERS:
33
+ raise ValueError("Saved keys support gemini, openai and anthropic only.")
34
+ return "android-localisation:api-key:" + provider
35
+
36
+
37
+ def _windows_api():
38
+ if os.name != "nt":
39
+ raise OSError("Saved key commands require Windows Credential Manager. Use environment variables on other platforms.")
40
+ api = ctypes.WinDLL("advapi32", use_last_error=True)
41
+ pointer = ctypes.POINTER(_Credential)
42
+ api.CredWriteW.argtypes = [pointer, wintypes.DWORD]
43
+ api.CredWriteW.restype = wintypes.BOOL
44
+ api.CredReadW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
45
+ ctypes.POINTER(pointer)]
46
+ api.CredReadW.restype = wintypes.BOOL
47
+ api.CredDeleteW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD]
48
+ api.CredDeleteW.restype = wintypes.BOOL
49
+ api.CredFree.argtypes = [ctypes.c_void_p]
50
+ api.CredFree.restype = None
51
+ return api
52
+
53
+
54
+ def _failure():
55
+ # Do not include credential values or structures in diagnostics.
56
+ return OSError("Windows Credential Manager failed (error {}).".format(ctypes.get_last_error()))
57
+
58
+
59
+ def save_key(provider, key):
60
+ target = _target(provider)
61
+ if not key or any(ord(char) < 33 or ord(char) > 126 for char in key):
62
+ raise ValueError("API key must be nonempty printable ASCII without whitespace.")
63
+ encoded = key.encode("ascii")
64
+ if len(encoded) > _MAX_BLOB:
65
+ raise ValueError("API key exceeds Windows Credential Manager's size limit.")
66
+ api = _windows_api()
67
+ blob = (wintypes.BYTE * len(encoded)).from_buffer_copy(encoded)
68
+ credential = _Credential()
69
+ credential.Type = _GENERIC
70
+ credential.TargetName = target
71
+ credential.Comment = "API key for android-localisation"
72
+ credential.CredentialBlobSize = len(encoded)
73
+ credential.CredentialBlob = blob
74
+ credential.Persist = _LOCAL_MACHINE
75
+ credential.UserName = provider
76
+ try:
77
+ if not api.CredWriteW(ctypes.byref(credential), 0):
78
+ raise _failure()
79
+ finally:
80
+ ctypes.memset(blob, 0, len(encoded))
81
+
82
+
83
+ def _read_key(provider, presence_only=False):
84
+ target = _target(provider)
85
+ api = _windows_api()
86
+ pointer = ctypes.POINTER(_Credential)()
87
+ if not api.CredReadW(target, _GENERIC, 0, ctypes.byref(pointer)):
88
+ if ctypes.get_last_error() == _NOT_FOUND:
89
+ return False if presence_only else None
90
+ raise _failure()
91
+ try:
92
+ credential = pointer.contents
93
+ if presence_only:
94
+ return True
95
+ size = credential.CredentialBlobSize
96
+ if not 0 < size <= _MAX_BLOB or not credential.CredentialBlob:
97
+ raise ValueError("Saved API key is invalid; save it again with credentials set.")
98
+ try:
99
+ key = ctypes.string_at(credential.CredentialBlob, size).decode("ascii")
100
+ except UnicodeDecodeError:
101
+ raise ValueError("Saved API key is invalid; save it again with credentials set.") from None
102
+ if any(ord(char) < 33 or ord(char) > 126 for char in key):
103
+ raise ValueError("Saved API key is invalid; save it again with credentials set.")
104
+ return key
105
+ finally:
106
+ # The Python string still exists while needed; this is not a memory vault.
107
+ if pointer.contents.CredentialBlob and pointer.contents.CredentialBlobSize <= _MAX_BLOB:
108
+ ctypes.memset(pointer.contents.CredentialBlob, 0, pointer.contents.CredentialBlobSize)
109
+ api.CredFree(pointer)
110
+
111
+
112
+ def remove_key(provider):
113
+ target = _target(provider)
114
+ api = _windows_api()
115
+ if api.CredDeleteW(target, _GENERIC, 0):
116
+ return True
117
+ if ctypes.get_last_error() == _NOT_FOUND:
118
+ return False
119
+ raise _failure()
120
+
121
+
122
+ def resolve_api_key(provider, supplied=None, base_url=None):
123
+ """Preserve argument/env precedence; use saved keys only for built-in endpoints."""
124
+ env_name = {"gemini": "GEMINI_API_KEY", "openai": "OPENAI_API_KEY",
125
+ "anthropic": "ANTHROPIC_API_KEY", "custom": "OPENAI_API_KEY"}.get(provider)
126
+ key = supplied or (os.environ.get(env_name) if env_name else None) or os.environ.get("API_KEY")
127
+ if key:
128
+ return key
129
+ if os.name != "nt" or provider not in PROVIDERS:
130
+ return None
131
+ if provider == "openai" and base_url and base_url != OPENAI_ENDPOINT:
132
+ return None # Never silently forward a saved OpenAI key to a custom host.
133
+ return _read_key(provider)
134
+
135
+
136
+ def add_arguments(parser):
137
+ parser.add_argument("action", choices=("set", "status", "remove"),
138
+ help="Save via hidden prompt, report presence, or remove the saved key")
139
+ parser.add_argument("--provider", choices=PROVIDERS, default="gemini",
140
+ help="Provider credential to manage (default: gemini)")
141
+
142
+
143
+ def _parse_args(args=None):
144
+ parser = argparse.ArgumentParser(description=__doc__)
145
+ add_arguments(parser)
146
+ return parser.parse_args(args)
147
+
148
+
149
+ def main(args=None):
150
+ if args is None or isinstance(args, list):
151
+ args = _parse_args(args)
152
+ try:
153
+ if args.action == "set":
154
+ if os.name != "nt":
155
+ raise OSError("Saved key commands require Windows Credential Manager. Use environment variables on other platforms.")
156
+ if not sys.stdin.isatty():
157
+ raise ValueError("Run credentials set yourself in an interactive terminal; piped keys are not accepted.")
158
+ with warnings.catch_warnings():
159
+ warnings.simplefilter("error", getpass.GetPassWarning)
160
+ key = getpass.getpass("{} API key (hidden): ".format(args.provider))
161
+ try:
162
+ save_key(args.provider, key)
163
+ finally:
164
+ key = None
165
+ print("Saved {} API key in Windows Credential Manager for this user.".format(args.provider))
166
+ elif args.action == "status":
167
+ present = _read_key(args.provider, presence_only=True)
168
+ print("{}: {}".format(args.provider, "saved" if present else "not saved"))
169
+ elif args.action == "remove":
170
+ removed = remove_key(args.provider)
171
+ print("{}: {}".format(args.provider, "saved key removed" if removed else "no saved key"))
172
+ else:
173
+ raise ValueError("Unknown credentials action.")
174
+ except (OSError, ValueError, getpass.GetPassWarning) as error:
175
+ # Specific errors above are safe; getpass fallback must never echo a key.
176
+ print("ERROR: {}".format(error))
177
+ return 1
178
+ except (EOFError, KeyboardInterrupt):
179
+ print("Key entry cancelled; saved credentials unchanged.")
180
+ return 1
181
+ return 0
182
+
183
+
184
+ if __name__ == "__main__":
185
+ raise SystemExit(main())
@@ -11,6 +11,7 @@ import time
11
11
  import unicodedata
12
12
 
13
13
  from android_localisation.resources import atomic_write
14
+ from android_localisation.credentials import resolve_api_key
14
15
  from android_localisation.locales import (
15
16
  GOOGLE_PLAY_LOCALES, language_items, normalize_play_locale, select_locales,
16
17
  )
@@ -47,7 +48,7 @@ def add_arguments(parser):
47
48
  parser.add_argument("--dry-run", action="store_true", help="Generate and validate a diff without saving (API usage applies)")
48
49
  parser.add_argument("--provider", choices=list(PROVIDER_MODELS), default="gemini", help="AI provider (default: gemini)")
49
50
  parser.add_argument("--model", help="Pin a model and disable automatic fallbacks (default: provider default; see models)")
50
- parser.add_argument("--api-key", help="API key, or provider-specific environment variable / API_KEY")
51
+ parser.add_argument("--api-key", help="API key; otherwise provider environment variable / API_KEY, then saved Windows key")
51
52
  parser.add_argument("--base-url", help="Custom OpenAI-compatible endpoint (required for custom provider)")
52
53
  parser.add_argument("--app-context", help="Short app description for terminology; listing remains the source of facts")
53
54
  parser.add_argument("--sleep", type=float, default=5.0, help="Seconds between requests, including validation retries (default: 5.0)")
@@ -176,11 +177,9 @@ def main(args=None):
176
177
  if args.provider == "custom" and (not args.model or not args.base_url):
177
178
  raise ValueError("custom provider requires --model and --base-url")
178
179
  models = [args.model] if args.model else model_list
179
- env_name = {"gemini": "GEMINI_API_KEY", "openai": "OPENAI_API_KEY",
180
- "anthropic": "ANTHROPIC_API_KEY", "custom": "OPENAI_API_KEY"}[args.provider]
181
- api_key = args.api_key or os.environ.get(env_name) or os.environ.get("API_KEY")
180
+ api_key = resolve_api_key(args.provider, args.api_key, args.base_url)
182
181
  if not api_key and args.provider != "custom":
183
- raise ValueError("provide --api-key or set {} / API_KEY".format(env_name))
182
+ raise ValueError("provide --api-key, a provider environment variable, or save a Windows key with credentials set; saved keys are not used with custom endpoints")
184
183
  except (OSError, ValueError, KeyError) as exc:
185
184
  print("ERROR: {}".format(exc))
186
185
  return 1
@@ -5,6 +5,7 @@ import socket
5
5
  import argparse
6
6
  import urllib.request
7
7
  import urllib.error
8
+ import urllib.parse
8
9
  import json
9
10
  import math
10
11
  import difflib
@@ -22,6 +23,7 @@ from android_localisation.resources import (
22
23
  from android_localisation.locales import (
23
24
  all_android_locales, android_locale, language_items, normalize_play_locale, select_locales,
24
25
  )
26
+ from android_localisation.credentials import resolve_api_key
25
27
 
26
28
  DEFAULT_RES_DIR = "app/src/main/res"
27
29
  DEFAULT_API_TIMEOUT = 180 # seconds (3 minutes) — large strings.xml files can exceed 60s
@@ -29,20 +31,19 @@ MAX_TIMEOUT_RETRIES = 2
29
31
 
30
32
  # Ordered list of models per provider.
31
33
  # First entry = default. Rest = automatic fallbacks (used only when user hasn't pinned a model).
32
- # Latest text-model lineup checked against official catalogs on 2026-10-02.
33
- # Do not add older-generation models as automatic fallbacks.
34
+ # User-selected defaults with one fallback each; changed model IDs checked on 2026-10-05.
34
35
  PROVIDER_MODELS = {
35
36
  "gemini": [
36
- "gemini-3.8-flash",
37
+ "gemini-3.5-flash-lite",
38
+ "gemini-3.5-flash",
37
39
  ],
38
40
  "openai": [
39
41
  "gpt-6-luna",
40
- "gpt-6.1-sol",
41
- "gpt-6-astra",
42
+ "gpt-5.6-terra",
42
43
  ],
43
44
  "anthropic": [
45
+ "claude-haiku-4-5",
44
46
  "claude-sonnet-5-5",
45
- "claude-opus-5-5",
46
47
  ],
47
48
  "custom": [], # user must specify --model
48
49
  }
@@ -148,7 +149,7 @@ def clean_xml_response(result):
148
149
 
149
150
  def _read_error_body(e):
150
151
  try:
151
- return e.read().decode("utf-8", errors="replace")[:500]
152
+ return e.read().decode("utf-8", errors="replace")
152
153
  except Exception:
153
154
  return "(could not read error body)"
154
155
 
@@ -173,27 +174,43 @@ def _is_timeout_error(exc):
173
174
  )
174
175
 
175
176
 
176
- def _urlopen_with_retries(req, provider_label, timeout):
177
+ def _redact_error(value, api_key):
178
+ text = str(value)
179
+ if api_key:
180
+ for secret in sorted({api_key, urllib.parse.quote(api_key, safe=""),
181
+ urllib.parse.quote_plus(api_key), json.dumps(api_key)[1:-1]},
182
+ key=len, reverse=True):
183
+ text = text.replace(secret, "[REDACTED]")
184
+ return text[:500]
185
+
186
+
187
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
188
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
189
+ return None # Do not forward authentication headers to redirect targets.
190
+
191
+
192
+ def _urlopen_with_retries(req, provider_label, timeout, api_key=None):
177
193
  """
178
194
  Execute an HTTP request with timeout/network error handling and retries on timeout.
179
195
  Returns (response_bytes, model_not_found).
180
196
  """
197
+ opener = urllib.request.build_opener(_NoRedirect())
181
198
  for attempt in range(MAX_TIMEOUT_RETRIES + 1):
182
199
  if attempt > 0:
183
200
  wait = attempt * 3
184
201
  print(f" 🔁 {provider_label} timed out — retrying ({attempt}/{MAX_TIMEOUT_RETRIES}) in {wait}s...")
185
202
  time.sleep(wait)
186
203
  try:
187
- with urllib.request.urlopen(req, timeout=timeout) as response:
204
+ with opener.open(req, timeout=timeout) as response:
188
205
  return response.read(), False
189
206
  except urllib.error.HTTPError as e:
190
207
  body = _read_error_body(e)
191
208
  model_gone = _is_model_not_found(e.code, body)
192
- print(f" ❌ {provider_label} API Error: {e.code} - {body}")
209
+ print(f" ❌ {provider_label} API Error: {e.code} - {_redact_error(body, api_key)}")
193
210
  return None, model_gone
194
211
  except (TimeoutError, socket.timeout, urllib.error.URLError) as e:
195
212
  if not _is_timeout_error(e):
196
- print(f" ❌ {provider_label} network error: {e.reason}")
213
+ print(f" ❌ {provider_label} network error: {_redact_error(e.reason, api_key)}")
197
214
  return None, False
198
215
  if attempt < MAX_TIMEOUT_RETRIES:
199
216
  continue
@@ -203,11 +220,11 @@ def _urlopen_with_retries(req, provider_label, timeout):
203
220
 
204
221
 
205
222
  def call_gemini(api_key, model, prompt, timeout=DEFAULT_API_TIMEOUT):
206
- url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
207
- headers = {"Content-Type": "application/json"}
223
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
224
+ headers = {"Content-Type": "application/json", "x-goog-api-key": api_key}
208
225
  data = {"contents": [{"parts": [{"text": prompt}]}]}
209
226
  req = urllib.request.Request(url, data=json.dumps(data).encode("utf-8"), headers=headers, method="POST")
210
- raw, model_gone = _urlopen_with_retries(req, "Gemini", timeout)
227
+ raw, model_gone = _urlopen_with_retries(req, "Gemini", timeout, api_key)
211
228
  if raw is None:
212
229
  return None, model_gone
213
230
  result = json.loads(raw.decode("utf-8"))
@@ -217,7 +234,7 @@ def call_gemini(api_key, model, prompt, timeout=DEFAULT_API_TIMEOUT):
217
234
  return None, False
218
235
  candidate = candidates[0]
219
236
  if candidate.get("finishReason") not in (None, "STOP"):
220
- print(" ❌ Gemini response was incomplete or blocked: {}".format(candidate.get("finishReason")))
237
+ print(" ❌ Gemini response was incomplete or blocked: {}".format(_redact_error(candidate.get("finishReason"), api_key)))
221
238
  return None, False
222
239
  return "".join(part.get("text", "") for part in candidate.get("content", {}).get("parts", [])
223
240
  if not part.get("thought")), False
@@ -233,7 +250,7 @@ def call_openai_compatible(api_key, base_url, model, prompt, timeout=DEFAULT_API
233
250
  "messages": [{"role": "user", "content": prompt}],
234
251
  }
235
252
  req = urllib.request.Request(base_url, data=json.dumps(data).encode("utf-8"), headers=headers, method="POST")
236
- raw, model_gone = _urlopen_with_retries(req, "OpenAI (compatible)", timeout)
253
+ raw, model_gone = _urlopen_with_retries(req, "OpenAI (compatible)", timeout, api_key)
237
254
  if raw is None:
238
255
  return None, model_gone
239
256
  result = json.loads(raw.decode("utf-8"))
@@ -242,7 +259,7 @@ def call_openai_compatible(api_key, base_url, model, prompt, timeout=DEFAULT_API
242
259
  print(" ❌ OpenAI returned no choices.")
243
260
  return None, False
244
261
  if choices[0].get("finish_reason") not in (None, "stop"):
245
- print(" ❌ OpenAI-compatible response was incomplete: {}".format(choices[0].get("finish_reason")))
262
+ print(" ❌ OpenAI-compatible response was incomplete: {}".format(_redact_error(choices[0].get("finish_reason"), api_key)))
246
263
  return None, False
247
264
  return choices[0].get("message", {}).get("content", ""), False
248
265
 
@@ -260,7 +277,7 @@ def call_anthropic(api_key, model, prompt, timeout=DEFAULT_API_TIMEOUT):
260
277
  "messages": [{"role": "user", "content": prompt}],
261
278
  }
262
279
  req = urllib.request.Request(url, data=json.dumps(data).encode("utf-8"), headers=headers, method="POST")
263
- raw, model_gone = _urlopen_with_retries(req, "Anthropic", timeout)
280
+ raw, model_gone = _urlopen_with_retries(req, "Anthropic", timeout, api_key)
264
281
  if raw is None:
265
282
  return None, model_gone
266
283
  result = json.loads(raw.decode("utf-8"))
@@ -269,7 +286,7 @@ def call_anthropic(api_key, model, prompt, timeout=DEFAULT_API_TIMEOUT):
269
286
  print(" ❌ Anthropic returned no content.")
270
287
  return None, False
271
288
  if result.get("stop_reason") not in (None, "end_turn"):
272
- print(" ❌ Anthropic response was incomplete: {}".format(result.get("stop_reason")))
289
+ print(" ❌ Anthropic response was incomplete: {}".format(_redact_error(result.get("stop_reason"), api_key)))
273
290
  return None, False
274
291
  return "".join(block.get("text", "") for block in content if block.get("type") == "text"), False
275
292
 
@@ -327,7 +344,7 @@ def _parse_args(args=None):
327
344
  add_flexible_arguments(parser)
328
345
  parser.add_argument("--provider", choices=["gemini", "openai", "anthropic", "custom"], default="gemini", help="AI provider (default: gemini)")
329
346
  parser.add_argument("--model", help="Pin any supported model and disable fallbacks (default: provider default; see models)")
330
- parser.add_argument("--api-key", help="API key, or set GEMINI_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / API_KEY")
347
+ parser.add_argument("--api-key", help="API key; otherwise use provider environment variable / API_KEY, then saved Windows key")
331
348
  parser.add_argument("--base-url", help="Custom OpenAI-compatible endpoint URL (required for 'custom' provider)")
332
349
  parser.add_argument("--app-context", help="Short description of your app for better translations")
333
350
  parser.add_argument("--sleep", type=float, default=5.0, help="Seconds between API requests (default: 5.0)")
@@ -382,16 +399,14 @@ def main(args=None):
382
399
  fallback_models = model_list[1:]
383
400
 
384
401
  # Resolve API key
385
- api_key = args.api_key
386
- if not api_key:
387
- if provider == "gemini": api_key = os.environ.get("GEMINI_API_KEY")
388
- elif provider in ("openai", "custom"): api_key = os.environ.get("OPENAI_API_KEY")
389
- elif provider == "anthropic": api_key = os.environ.get("ANTHROPIC_API_KEY")
390
- if not api_key:
391
- api_key = os.environ.get("API_KEY")
402
+ try:
403
+ api_key = resolve_api_key(provider, args.api_key, args.base_url)
404
+ except (OSError, ValueError) as exc:
405
+ print("❌ ERROR: {}".format(exc))
406
+ return 1
392
407
 
393
408
  if not api_key and provider != "custom":
394
- print("❌ ERROR: Please provide an API key via --api-key or the appropriate environment variable.")
409
+ print("❌ ERROR: Provide --api-key, a provider environment variable, or save a Windows key with credentials set. Saved keys are not used with custom endpoints.")
395
410
  return 1
396
411
 
397
412
  if provider == "custom" and not args.base_url:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: android-localisation
3
- Version: 1.5.0
3
+ Version: 1.6.2
4
4
  Summary: Zero-dependency Android strings.xml and Google Play store listing translation using LLMs (Gemini, OpenAI, Anthropic, Ollama).
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/BharathKmalviya/android-llm-localization
@@ -127,12 +127,16 @@ When you run `android-localise translate --api-key YOUR_KEY`, here's exactly wha
127
127
  4. Parses the response and checks duplicate/unexpected/missing resources, attributes, inline markup, item structure, control escapes and format arguments. A valid result replaces the file atomically; new folders are created only when saving. `--skip-existing` validates existing files and skips them without API calls. `--dry-run` shows a diff without writing any files or creating folders
128
128
  5. Waits 5 seconds between each language request to avoid hitting API rate limits
129
129
 
130
+ Keys resolve from `--api-key`, then provider environment variables / `API_KEY`,
131
+ then saved Windows credentials for built-in provider endpoints. The CLI never
132
+ prompts for a key during translation; use `credentials set` yourself beforehand.
133
+
130
134
  **Defaults used when you don't specify anything:**
131
135
 
132
136
  | What | Default |
133
137
  |---|---|
134
138
  | Provider | Gemini |
135
- | Model | `gemini-3.8-flash` |
139
+ | Model | `gemini-3.5-flash-lite` |
136
140
  | Source directory | `app/src/main/res` |
137
141
  | Delay between requests | 5 seconds |
138
142
  | App context | none (generic prompt) |
@@ -226,7 +230,7 @@ android-localise translate \
226
230
 
227
231
  | Flag | What it does | Default |
228
232
  |---|---|---|
229
- | `--api-key` | Your API key | reads from env var |
233
+ | `--api-key` | Explicit key override; omit for environment or saved Windows credentials | environment, then saved Windows key |
230
234
  | `--provider` | Which AI to use: `gemini` `openai` `anthropic` `custom` | `gemini` |
231
235
  | `--model` | Specific model to use | see [Providers](#providers) |
232
236
  | `--languages` | Comma-separated Android/conventional codes and/or `all`, e.g. `all,zu` or `hi,es-ES,b+es+419` | scan existing destination locale folders |
@@ -305,7 +309,7 @@ Console import file. The CLI does not upload or publish a listing.
305
309
  | `--dry-run` | Generate, validate and display diffs without files/directories being written | off; API usage applies |
306
310
  | `--provider` | `gemini`, `openai`, `anthropic`, `custom` | `gemini` |
307
311
  | `--model` | Pin any supported model and disable fallbacks | provider default |
308
- | `--api-key` | Key, or provider-specific environment variable / `API_KEY` | environment |
312
+ | `--api-key` | Explicit key override; omit for environment or saved Windows credentials | environment, then saved Windows key |
309
313
  | `--base-url` | OpenAI-compatible endpoint; required for `custom` | provider endpoint |
310
314
  | `--app-context` | Terminology context; source copy remains the source of facts | none |
311
315
  | `--sleep` | Delay between requests, including correction/fallback requests | `5.0` seconds |
@@ -480,18 +484,18 @@ environment behavior. All commands can also run through `python -m android_local
480
484
 
481
485
  ## Providers
482
486
 
483
- By default the tool uses Gemini with `gemini-3.8-flash`. You can switch providers with `--provider` and optionally pin a specific model with `--model`. Configured defaults and fallbacks use only the latest general-purpose text-model lineup, with defaults favoring speed and cost within that lineup.
487
+ By default the tool uses Gemini 3.5 Flash-Lite (`gemini-3.5-flash-lite`) for both XML and store listing translation. You can switch providers with `--provider` and optionally pin a specific model with `--model`.
484
488
 
485
- | Provider | Default model | Fallbacks | API key env var |
489
+ | Provider | Default model | Fallback | API key env var |
486
490
  |---|---|---|---|
487
- | `gemini` _(default)_ | `gemini-3.8-flash` | none | `GEMINI_API_KEY` |
488
- | `openai` | `gpt-6-luna` | `gpt-6.1-sol` → `gpt-6-astra` | `OPENAI_API_KEY` |
489
- | `anthropic` | `claude-sonnet-5-5` | `claude-opus-5-5` | `ANTHROPIC_API_KEY` |
491
+ | `gemini` _(default)_ | `gemini-3.5-flash-lite` | `gemini-3.5-flash` | `GEMINI_API_KEY` |
492
+ | `openai` | `gpt-6-luna` | `gpt-5.6-terra` | `OPENAI_API_KEY` |
493
+ | `anthropic` | `claude-haiku-4-5` | `claude-sonnet-5-5` | `ANTHROPIC_API_KEY` |
490
494
  | `custom` | set with `--model` | none | `OPENAI_API_KEY` (optional) |
491
495
 
492
- If the default model returns a "model not found" error (e.g. it was deprecated), the tool automatically retries with the next fallback. If you pin a model with `--model`, no fallback is used.
496
+ Each hosted provider has exactly one automatic fallback. If the default model returns a "model not found" error (e.g. it was deprecated), the tool retries with that fallback. Authentication, quota and network errors do not trigger a model fallback. If you pin a model with `--model`, no fallback is used.
493
497
 
494
- Model IDs and compatibility checked against [Google's model catalog](https://ai.google.dev/gemini-api/docs/models), [OpenAI's model catalog](https://developers.openai.com/api/docs/models) and [Anthropic's model catalog](https://platform.claude.com/docs/en/models/overview) on **2026-10-02**. Older Gemini, GPT-5 and Haiku 4.5 models are excluded from automatic selection. Gemini has no fallback in its latest stable text generation. OpenAI and Anthropic fallbacks are higher-cost models; pin `--model` to avoid automatic tier changes. Explicit model selection and custom/local endpoints remain available. The catalog is bundled with each CLI release, rather than automatically discovering models at runtime.
498
+ These default/fallback pairs follow the selected configuration. Gemini model IDs were checked against [Flash-Lite](https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash-lite) and [Flash](https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash) documentation; the OpenAI fallback against [GPT-5.6 Terra](https://developers.openai.com/api/docs/models/gpt-5.6-terra); and Anthropic's default/fallback against its [model migration guide](https://platform.claude.com/docs/en/models/sonnet-5-5/migration-guide) on **2026-10-05**. The existing OpenAI default was checked on **2026-10-02**. Pin `--model` to avoid automatic model changes. Explicit model selection and custom/local endpoints remain available. The catalog is bundled with each CLI release, rather than automatically discovering models at runtime.
495
499
 
496
500
  OpenAI and local providers retain the Chat Completions request format. Provider replies indicating truncation or blocked/incomplete output are rejected. Anthropic allows up to 16,384 output tokens per request and text blocks are collected separately from thinking blocks. Large files can still exceed a model's limits; automatic batching is future work.
497
501
 
@@ -525,9 +529,72 @@ android-localise translate \
525
529
 
526
530
  ---
527
531
 
532
+ ## Save a key once on Windows
533
+
534
+ Run this yourself in your terminal:
535
+
536
+ ```bash
537
+ android-localise credentials set --provider gemini
538
+ ```
539
+
540
+ Enter your key at the hidden prompt. It is stored in **Windows Credential
541
+ Manager**, for your Windows user on this computer, and persists across terminal
542
+ and computer restarts. The CLI does not write a plaintext key file or modify
543
+ environment variables. Running `set` again replaces that provider's saved key.
544
+ There is no key argument or piped-input option for this command; it requires an
545
+ interactive terminal and refuses a prompt that cannot hide input.
546
+
547
+ Then you or an AI assistant can run ordinary commands without including a key:
548
+
549
+ ```bash
550
+ android-localise translate --languages hi,es
551
+ android-localise store-listing --source listing.json --languages hi-IN,es-ES
552
+ android-localise credentials status --provider gemini
553
+ android-localise credentials remove --provider gemini
554
+ ```
555
+
556
+ `status` reports only `saved` or `not saved`; no command displays the saved value.
557
+ `remove` deletes only this CLI's saved entry for that provider; it does not revoke
558
+ the provider key or clear environment-variable overrides. The same commands also
559
+ work with `python -m android_localisation credentials ...`.
560
+
561
+ | Argument | Description | Default |
562
+ |---|---|---|
563
+ | `set`, `status`, `remove` | Hidden entry, presence check, or deletion | required action |
564
+ | `--provider` | `gemini`, `openai` or `anthropic` | `gemini` |
565
+
566
+ Both translation commands use this precedence: `--api-key` → provider-specific
567
+ environment variable → `API_KEY` → saved Windows key. Existing overrides retain
568
+ their behavior; if an old environment key is set, saving a new key does not
569
+ override it. Saved OpenAI keys load only for its default endpoint; `custom`
570
+ providers and other `--base-url` endpoints continue using explicit keys or
571
+ environment variables. Saved keys are not automatically sent to custom hosts.
572
+ Provider requests reject HTTP redirects, so use a custom endpoint's final URL.
573
+ Gemini authentication uses a header rather than a URL query parameter, and
574
+ API/network error messages redact the key used for that request before display.
575
+
576
+ This keeps keys out of chat, command arguments and routine CLI output. It
577
+ **does not isolate secrets from an AI or other program with unrestricted access
578
+ under your Windows account**: Windows permits programs running as that user to
579
+ read their credentials. The key is also present in memory during an API request.
580
+ For stronger separation, use a restricted runner or separately secured proxy.
581
+ See [Microsoft's credential API documentation](https://learn.microsoft.com/en-us/windows/win32/api/wincred/nf-wincred-credreadw).
582
+
583
+ Saved-key commands are Windows-only, with no plaintext fallback. Other platforms
584
+ keep using environment variables or `--api-key`.
585
+
586
+ Manual check: save a key yourself, confirm input is hidden, open a new terminal
587
+ and check `status`. Run a small XML and listing preview without `--api-key`,
588
+ review the output, then remove the entry and confirm `not saved`. Ensure any key
589
+ environment overrides are absent when checking saved-key behavior.
590
+
591
+ ---
592
+
528
593
  ## Environment variables
529
594
 
530
- Set your API key as an env variable so you don't have to pass it every time:
595
+ Environment variables remain available on all platforms. For Windows saved
596
+ credentials, use the commands above instead of putting a key in shell history.
597
+ To use an environment variable:
531
598
 
532
599
  ```bash
533
600
  # macOS / Linux
@@ -5,6 +5,7 @@ pyproject.toml
5
5
  android_localisation/__init__.py
6
6
  android_localisation/__main__.py
7
7
  android_localisation/cli.py
8
+ android_localisation/credentials.py
8
9
  android_localisation/fix.py
9
10
  android_localisation/locales.py
10
11
  android_localisation/resources.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "android-localisation"
7
- version = "1.5.0"
7
+ version = "1.6.2"
8
8
  description = "Zero-dependency Android strings.xml and Google Play store listing translation using LLMs (Gemini, OpenAI, Anthropic, Ollama)."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }