Stop the admin provider UI from silently dropping cli_credentials_file; bump to 0.99.86

The CLI credentials upload stored claude_config.cli_credentials_file server-side,
but the path was then lost and CLI mode fell back to the OAuth2 credentials file:

- uploadClaudeCliFile() only showed a toast; unlike its sibling
  uploadFileChunked(), it never mirrored the stored path into providersData.
- saveProvider() posts providersData wholesale, and api_provider_save replaced
  the provider config outright, so a page loaded before the upload wiped the key
  on the next save.
- There was no field for cli_credentials_file — it appeared only in help text —
  so the value was invisible and unrecoverable once lost.

Preserve keys inside *_config blocks the client did not send, which fixes the
whole class rather than this one key: any server-set key would have been dropped
the same way. Only merges into a block the client actually sent, so a provider
type change still drops the old block and nothing is resurrected. A key the
client does send always wins, including falsy values like use_cli_mode=false.

Add the missing text field (admin only — DB users keep CLI credentials in
user_oauth2_credentials, not in claude_config), and mirror the upload result
into providersData. The chunk endpoint now also returns config_path, the tilde
form actually written to providers.json; file_path is absolute, so mirroring it
would post back a value that disagrees with what the server stored.
Co-Authored-By: 's avatarClaude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 77ac9262
......@@ -55,7 +55,7 @@ from .auth.qwen import QwenOAuth2
from .handlers import RequestHandler, RotationHandler, AutoselectHandler
from .utils import count_messages_tokens, split_messages_into_chunks, get_max_request_tokens_for_model, get_max_completion_tokens_for_model
__version__ = "0.99.85"
__version__ = "0.99.86"
__all__ = [
# Config
"config",
......
......@@ -1984,6 +1984,37 @@ async def dashboard_autoselect_save(request: Request, config: str = Form(...)):
# and trigger hot-reload of the in-memory config so no restart is needed.
# ---------------------------------------------------------------------------
def _preserve_unsent_config_keys(existing: dict | None, incoming: dict) -> dict:
"""
Keep keys inside a provider's *_config blocks that the client did not send.
The dashboard posts its whole in-memory provider object, but some keys are
only ever written server-side by the credential upload endpoint — e.g.
claude_config.cli_credentials_file, which the upload sets and the UI has no
field for. A page loaded before such an upload holds a stale config, so a
plain replace silently drops those keys on the next save.
Only merges into a *_config dict the client actually sent. If it omits the
block entirely (e.g. the provider type changed), the block is left to go, so
this cannot resurrect config for a type the provider no longer is.
"""
if not isinstance(existing, dict) or not isinstance(incoming, dict):
return incoming
for key, existing_block in existing.items():
if not key.endswith('_config') or not isinstance(existing_block, dict):
continue
incoming_block = incoming.get(key)
if not isinstance(incoming_block, dict):
continue
for sub_key, sub_value in existing_block.items():
if sub_key not in incoming_block:
incoming_block[sub_key] = sub_value
logger.info(
f"api_provider_save: preserved {key}.{sub_key} not sent by client"
)
return incoming
@router.post("/dashboard/api/provider")
async def api_provider_save(request: Request):
"""Create or update a single provider"""
......@@ -2013,6 +2044,9 @@ async def api_provider_save(request: Request):
full_config = json.load(f)
if 'providers' not in full_config or not isinstance(full_config['providers'], dict):
full_config['providers'] = {}
provider_config = _preserve_unsent_config_keys(
full_config['providers'].get(provider_id), provider_config
)
full_config['providers'][provider_id] = provider_config
save_path = Path.home() / '.aisbf' / 'providers.json'
save_path.parent.mkdir(parents=True, exist_ok=True)
......
......@@ -1777,6 +1777,9 @@ async def dashboard_provider_upload_chunk(
"complete": True,
"message": "File uploaded successfully",
"file_path": str(file_path),
# Same tilde form written into providers.json above, so a client
# mirroring this into its config posts back an identical value.
"config_path": str(file_path).replace(str(Path.home()), '~'),
"stored_filename": stored_filename
})
......
......@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "aisbf"
version = "0.99.85"
version = "0.99.86"
description = "AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations"
readme = "README.md"
license = "GPL-3.0-or-later"
......
......@@ -106,7 +106,7 @@ class InstallCommand(_install):
setup(
name="aisbf",
version="0.99.85",
version="0.99.86",
author="AISBF Contributors",
author_email="stefy@nexlab.net",
description="AISBF - AI Service Broker Framework || AI Should Be Free - A modular proxy server for managing multiple AI provider integrations",
......
......@@ -156,6 +156,8 @@
"cli_mode_active": "Claude CLI Mode Active",
"use_cli_mode": "Use Claude CLI mode",
"upload_cli_creds": "Override: Upload CLI Credentials File",
"cli_credentials_file": "CLI Credentials File Path",
"cli_credentials_file_desc": "Path to the Claude CLI .credentials.json used for CLI mode. Set automatically when you upload a file above; leave empty to derive credentials from the OAuth2 tokens.",
"provider_label": "Provider",
"model_name": "Model Name",
"weight": "Weight",
......
......@@ -788,6 +788,16 @@ async function uploadClaudeCliFile(providerKey, file) {
if (result.complete) {
statusEl.innerHTML = `<div style="color: #4ade80; display: flex; align-items: center; gap: 6px;"><span style="font-size: 1.1em;">&#10003;</span> ${window.i18n.interpolate(window.i18n.t('providers.cli_creds_saved'), {name: file.name})}</div>`;
showToast(window.i18n.interpolate(window.i18n.t('providers.cli_creds_saved'), {name: file.name}), 'success');
// Mirror the path the server just stored, so a later save of this
// page does not post a config that lacks it.
const storedPath = result.config_path || result.file_path;
if (storedPath) {
if (!providersData[providerKey].claude_config) {
providersData[providerKey].claude_config = {};
}
providersData[providerKey].claude_config.cli_credentials_file = storedPath;
setTimeout(() => renderProvidersList(), 2500);
}
return;
}
}
......@@ -1286,6 +1296,16 @@ function renderProviderDetails(key) {
<code style="background:var(--bg-panel);padding:1px 4px;border-radius:3px;">claude_config.cli_credentials_file</code>.
</small>
</div>
<div class="form-group">
<label>${window.i18n.t('providers.cli_credentials_file')}</label>
<input type="text" value="${claudeConfig.cli_credentials_file || ''}"
onchange="updateClaudeConfig('${key}', 'cli_credentials_file', this.value)"
placeholder="~/.claude/.credentials.json">
<small style="color: var(--color-muted); display: block; margin-top: 5px;">
${window.i18n.t('providers.cli_credentials_file_desc')}
</small>
</div>
<div id="claude-cli-upload-status-${key}" style="margin-top: 10px;"></div>
</div>
` : ''}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment