feat(credentials): add AI provider credential seeder and update documentation
This commit is contained in:
parent
16ad1b914a
commit
434f26d000
17
.env.example
17
.env.example
|
|
@ -29,6 +29,23 @@ AUTH_MODE=local
|
||||||
# REQUIRED when AUTH_MODE=local (must be non-placeholder and at least 50 chars).
|
# REQUIRED when AUTH_MODE=local (must be non-placeholder and at least 50 chars).
|
||||||
LOCAL_AUTH_TOKEN=
|
LOCAL_AUTH_TOKEN=
|
||||||
|
|
||||||
|
# --- AI provider credentials (local mode only) ---
|
||||||
|
# Pipeline reads local credential files on every boot and upserts provider rows
|
||||||
|
# automatically — no manual configuration required if you have Claude Code or
|
||||||
|
# Codex CLI installed:
|
||||||
|
#
|
||||||
|
# Anthropic ~/.claude/.credentials.json (claudeAiOauth.accessToken)
|
||||||
|
# OpenAI ~/.codex/auth.json (tokens.access_token)
|
||||||
|
#
|
||||||
|
# Override the credential file paths if they live elsewhere:
|
||||||
|
# CLAUDE_CREDENTIALS_PATH=/path/to/.credentials.json
|
||||||
|
# CODEX_CREDENTIALS_PATH=/path/to/auth.json
|
||||||
|
#
|
||||||
|
# Supplement with explicit API keys (used alongside session tokens):
|
||||||
|
# ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
# OPENAI_API_KEY=sk-...
|
||||||
|
# OPENAI_BASE_URL=http://localhost:11434/v1 # Ollama, Azure, etc.
|
||||||
|
|
||||||
# --- frontend settings ---
|
# --- frontend settings ---
|
||||||
# REQUIRED: Public URL used by the browser to reach the API.
|
# REQUIRED: Public URL used by the browser to reach the API.
|
||||||
# Use `auto` to target the same host currently serving Pipeline on port 8001.
|
# Use `auto` to target the same host currently serving Pipeline on port 8001.
|
||||||
|
|
|
||||||
56
README.md
56
README.md
|
|
@ -183,6 +183,62 @@ Set `AUTH_MODE=clerk` and configure `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLE
|
||||||
- Backend: [`backend/.env.example`](./backend/.env.example)
|
- Backend: [`backend/.env.example`](./backend/.env.example)
|
||||||
- Frontend: [`frontend/.env.example`](./frontend/.env.example)
|
- Frontend: [`frontend/.env.example`](./frontend/.env.example)
|
||||||
|
|
||||||
|
## AI provider credentials
|
||||||
|
|
||||||
|
When running in `AUTH_MODE=local`, Mission Control automatically configures AI provider credentials on startup by reading the credential files written by the Claude Code and Codex CLI VS Code extensions.
|
||||||
|
|
||||||
|
### What gets read and where
|
||||||
|
|
||||||
|
| Provider | File | Field |
|
||||||
|
|---|---|---|
|
||||||
|
| Anthropic / Claude | `~/.claude/.credentials.json` | `claudeAiOauth.accessToken` |
|
||||||
|
| OpenAI / GPT | `~/.codex/auth.json` | `tokens.access_token` |
|
||||||
|
|
||||||
|
These are the same files the extensions update when you log in or out.
|
||||||
|
No manual configuration is required if either extension is installed and authenticated.
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
On every boot the backend seeds `provider_credentials` rows from those files.
|
||||||
|
A background watcher then polls the file modification times every **60 seconds**.
|
||||||
|
When a change is detected (login, logout, token refresh) the affected row is updated automatically — you are reconfigured within one polling cycle of any credential change.
|
||||||
|
|
||||||
|
Seeding is idempotent: rows are only written when the stored token differs from what is on disk.
|
||||||
|
If a file is absent or the token has expired, that provider is skipped without error.
|
||||||
|
|
||||||
|
### Override credential file paths
|
||||||
|
|
||||||
|
Set environment variables to point to non-default locations:
|
||||||
|
|
||||||
|
```env
|
||||||
|
CLAUDE_CREDENTIALS_PATH=/path/to/.credentials.json
|
||||||
|
CODEX_CREDENTIALS_PATH=/path/to/auth.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supplement with explicit API keys
|
||||||
|
|
||||||
|
Session tokens from the local files grant subscription-level access.
|
||||||
|
To also configure standard API keys (for rate-limit or billing endpoints), add these to `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
OPENAI_BASE_URL=http://localhost:11434/v1 # Ollama, Azure, or any OpenAI-compatible endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run the seeder manually
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# one-shot seed
|
||||||
|
python backend/scripts/seed_provider_credentials.py
|
||||||
|
|
||||||
|
# continuous watcher (re-seeds every 60 s when files change)
|
||||||
|
python backend/scripts/seed_provider_credentials.py --watch
|
||||||
|
|
||||||
|
# custom poll interval
|
||||||
|
python backend/scripts/seed_provider_credentials.py --watch --interval 30
|
||||||
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Complete guides for deployment, production, troubleshooting, and testing are in [`/docs`](./docs/).
|
Complete guides for deployment, production, troubleshooting, and testing are in [`/docs`](./docs/).
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
|
@ -42,6 +43,7 @@ from app.api.users import router as users_router
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.error_handling import install_error_handling
|
from app.core.error_handling import install_error_handling
|
||||||
from app.core.logging import configure_logging, get_logger
|
from app.core.logging import configure_logging, get_logger
|
||||||
|
from app.core.auth_mode import AuthMode
|
||||||
from app.core.rate_limit import validate_rate_limit_redis
|
from app.core.rate_limit import validate_rate_limit_redis
|
||||||
from app.core.rate_limit_backend import RateLimitBackend
|
from app.core.rate_limit_backend import RateLimitBackend
|
||||||
from app.core.security_headers import SecurityHeadersMiddleware
|
from app.core.security_headers import SecurityHeadersMiddleware
|
||||||
|
|
@ -471,10 +473,38 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||||
else:
|
else:
|
||||||
logger.info("app.lifecycle.rate_limit backend=memory")
|
logger.info("app.lifecycle.rate_limit backend=memory")
|
||||||
seed_scheduled_forgejo_sync()
|
seed_scheduled_forgejo_sync()
|
||||||
|
if settings.auth_mode == AuthMode.LOCAL:
|
||||||
|
# Seed provider credentials from local credential files on every boot,
|
||||||
|
# then start a background watcher that re-seeds whenever a file changes
|
||||||
|
# (VS Code extension login/logout events).
|
||||||
|
credential_watcher_task = None
|
||||||
|
try:
|
||||||
|
from scripts.seed_provider_credentials import (
|
||||||
|
seed as seed_providers,
|
||||||
|
watch as watch_providers,
|
||||||
|
)
|
||||||
|
changed = await seed_providers(verbose=False)
|
||||||
|
if changed:
|
||||||
|
logger.info("app.lifecycle.provider_credentials seeded count=%d", changed)
|
||||||
|
else:
|
||||||
|
logger.debug("app.lifecycle.provider_credentials already current")
|
||||||
|
credential_watcher_task = asyncio.create_task(
|
||||||
|
watch_providers(), name="provider_credentials_watcher"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("app.lifecycle.provider_credentials setup_failed error=%s", exc)
|
||||||
logger.info("app.lifecycle.started")
|
logger.info("app.lifecycle.started")
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
if settings.auth_mode == AuthMode.LOCAL:
|
||||||
|
task = locals().get("credential_watcher_task")
|
||||||
|
if task and not task.done():
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
logger.info("app.lifecycle.stopped")
|
logger.info("app.lifecycle.stopped")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,392 @@
|
||||||
|
"""Idempotent seeder for local AI provider credentials.
|
||||||
|
|
||||||
|
Reads session tokens from the local Claude Code and Codex CLI credential
|
||||||
|
files and upserts them as ProviderCredential rows so Pipeline recognises
|
||||||
|
the providers as configured.
|
||||||
|
|
||||||
|
Sources
|
||||||
|
───────
|
||||||
|
Anthropic / Claude
|
||||||
|
~/.claude/.credentials.json (claudeAiOauth.accessToken)
|
||||||
|
Override path: CLAUDE_CREDENTIALS_PATH env var
|
||||||
|
|
||||||
|
OpenAI / GPT
|
||||||
|
~/.codex/auth.json (tokens.access_token)
|
||||||
|
Override path: CODEX_CREDENTIALS_PATH env var
|
||||||
|
|
||||||
|
API keys (optional fallback / supplement)
|
||||||
|
ANTHROPIC_API_KEY
|
||||||
|
OPENAI_API_KEY
|
||||||
|
OPENAI_BASE_URL
|
||||||
|
|
||||||
|
Safe to run on every boot — rows are only created or updated when a token
|
||||||
|
or key value differs from what is already stored.
|
||||||
|
|
||||||
|
Usage
|
||||||
|
─────
|
||||||
|
# standalone
|
||||||
|
python scripts/seed_provider_credentials.py
|
||||||
|
|
||||||
|
# called automatically from main.py lifespan when AUTH_MODE=local
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(BACKEND_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
# ── credential-file readers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _read_claude_session_key() -> str | None:
|
||||||
|
"""Read the Claude Code OAuth access token from ~/.claude/.credentials.json."""
|
||||||
|
path = os.environ.get("CLAUDE_CREDENTIALS_PATH", "").strip()
|
||||||
|
if not path:
|
||||||
|
path = os.path.join(os.path.expanduser("~"), ".claude", ".credentials.json")
|
||||||
|
try:
|
||||||
|
with open(path) as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (FileNotFoundError, PermissionError, ValueError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
oauth = data.get("claudeAiOauth")
|
||||||
|
if not isinstance(oauth, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = oauth.get("accessToken")
|
||||||
|
expires_at = oauth.get("expiresAt")
|
||||||
|
|
||||||
|
if not isinstance(token, str) or not token:
|
||||||
|
return None
|
||||||
|
if isinstance(expires_at, (int, float)) and expires_at > 0:
|
||||||
|
if expires_at <= time.time() * 1000:
|
||||||
|
return None # expired
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def _read_openai_session_key() -> str | None:
|
||||||
|
"""Read the Codex CLI JWT access token from ~/.codex/auth.json."""
|
||||||
|
path = os.environ.get("CODEX_CREDENTIALS_PATH", "").strip()
|
||||||
|
if not path:
|
||||||
|
path = os.path.join(os.path.expanduser("~"), ".codex", "auth.json")
|
||||||
|
try:
|
||||||
|
with open(path) as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (FileNotFoundError, PermissionError, ValueError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
tokens = data.get("tokens")
|
||||||
|
if not isinstance(tokens, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
token = tokens.get("access_token")
|
||||||
|
if not isinstance(token, str) or not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check JWT expiry without verifying the signature.
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
try:
|
||||||
|
pad = parts[1] + "=" * (4 - len(parts[1]) % 4)
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(pad))
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if isinstance(exp, (int, float)) and exp <= time.time():
|
||||||
|
return None # expired
|
||||||
|
except Exception:
|
||||||
|
pass # Can't decode — proceed optimistically
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
# ── spec dataclass ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _ProviderSpec:
|
||||||
|
provider: str
|
||||||
|
account_key: str
|
||||||
|
display_name: str
|
||||||
|
api_key: str | None = None
|
||||||
|
session_key: str | None = None
|
||||||
|
base_url: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_credentials(self) -> bool:
|
||||||
|
return bool(self.api_key or self.session_key or self.base_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_specs() -> list[_ProviderSpec]:
|
||||||
|
specs: list[_ProviderSpec] = []
|
||||||
|
|
||||||
|
# Anthropic: session token from local file, API key from env.
|
||||||
|
anthropic = _ProviderSpec(
|
||||||
|
provider="anthropic",
|
||||||
|
account_key="default",
|
||||||
|
display_name="Anthropic (local)",
|
||||||
|
session_key=_read_claude_session_key(),
|
||||||
|
api_key=os.environ.get("ANTHROPIC_API_KEY", "").strip() or None,
|
||||||
|
base_url=os.environ.get("ANTHROPIC_BASE_URL", "").strip() or None,
|
||||||
|
)
|
||||||
|
if anthropic.has_credentials:
|
||||||
|
specs.append(anthropic)
|
||||||
|
|
||||||
|
# OpenAI: session token from local Codex file, API key from env.
|
||||||
|
openai = _ProviderSpec(
|
||||||
|
provider="openai",
|
||||||
|
account_key="default",
|
||||||
|
display_name="OpenAI (local)",
|
||||||
|
session_key=_read_openai_session_key(),
|
||||||
|
api_key=os.environ.get("OPENAI_API_KEY", "").strip() or None,
|
||||||
|
base_url=os.environ.get("OPENAI_BASE_URL", "").strip() or None,
|
||||||
|
)
|
||||||
|
if openai.has_credentials:
|
||||||
|
specs.append(openai)
|
||||||
|
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
# ── upsert logic ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def seed(*, verbose: bool = True) -> int:
|
||||||
|
"""Upsert provider credentials from local credential files and env vars.
|
||||||
|
|
||||||
|
Returns the number of rows created or updated.
|
||||||
|
"""
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from app.core.auth import LOCAL_AUTH_USER_ID, LOCAL_AUTH_EMAIL, LOCAL_AUTH_NAME
|
||||||
|
from app.db import crud
|
||||||
|
from app.db.session import async_session_maker, init_db
|
||||||
|
from app.models.provider_credentials import ProviderCredential
|
||||||
|
from app.models.users import User
|
||||||
|
from app.services.organizations import ensure_member_for_user
|
||||||
|
|
||||||
|
specs = _collect_specs()
|
||||||
|
if not specs:
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
"seed_provider_credentials: no credentials found — "
|
||||||
|
"ensure ~/.claude/.credentials.json or ~/.codex/auth.json exist, "
|
||||||
|
"or set ANTHROPIC_API_KEY / OPENAI_API_KEY"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
await init_db()
|
||||||
|
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
# Ensure the local user + org exist before we can write credentials.
|
||||||
|
user, _created = await crud.get_or_create(
|
||||||
|
session,
|
||||||
|
User,
|
||||||
|
clerk_user_id=LOCAL_AUTH_USER_ID,
|
||||||
|
defaults={"email": LOCAL_AUTH_EMAIL, "name": LOCAL_AUTH_NAME},
|
||||||
|
)
|
||||||
|
if not user.email:
|
||||||
|
user.email = LOCAL_AUTH_EMAIL
|
||||||
|
if not user.name:
|
||||||
|
user.name = LOCAL_AUTH_NAME
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
|
member = await ensure_member_for_user(session, user)
|
||||||
|
organization_id = member.organization_id
|
||||||
|
|
||||||
|
changed = 0
|
||||||
|
for spec in specs:
|
||||||
|
existing = (
|
||||||
|
await session.exec(
|
||||||
|
select(ProviderCredential).where(
|
||||||
|
ProviderCredential.organization_id == organization_id,
|
||||||
|
ProviderCredential.provider == spec.provider,
|
||||||
|
ProviderCredential.account_key == spec.account_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
cred = ProviderCredential(
|
||||||
|
organization_id=organization_id,
|
||||||
|
provider=spec.provider,
|
||||||
|
account_key=spec.account_key,
|
||||||
|
display_name=spec.display_name,
|
||||||
|
api_key=spec.api_key,
|
||||||
|
api_key_last_four=spec.api_key[-4:] if spec.api_key else None,
|
||||||
|
session_key=spec.session_key,
|
||||||
|
session_key_last_four=spec.session_key[-4:] if spec.session_key else None,
|
||||||
|
base_url=spec.base_url,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
session.add(cred)
|
||||||
|
await session.commit()
|
||||||
|
changed += 1
|
||||||
|
if verbose:
|
||||||
|
_log_created(spec)
|
||||||
|
else:
|
||||||
|
dirty = False
|
||||||
|
if spec.api_key is not None and existing.api_key != spec.api_key:
|
||||||
|
existing.api_key = spec.api_key
|
||||||
|
existing.api_key_last_four = spec.api_key[-4:]
|
||||||
|
dirty = True
|
||||||
|
if spec.session_key is not None and existing.session_key != spec.session_key:
|
||||||
|
existing.session_key = spec.session_key
|
||||||
|
existing.session_key_last_four = spec.session_key[-4:]
|
||||||
|
dirty = True
|
||||||
|
if spec.base_url != existing.base_url:
|
||||||
|
existing.base_url = spec.base_url
|
||||||
|
dirty = True
|
||||||
|
if not existing.active:
|
||||||
|
existing.active = True
|
||||||
|
dirty = True
|
||||||
|
if dirty:
|
||||||
|
session.add(existing)
|
||||||
|
await session.commit()
|
||||||
|
changed += 1
|
||||||
|
if verbose:
|
||||||
|
_log_updated(spec)
|
||||||
|
else:
|
||||||
|
if verbose:
|
||||||
|
print(
|
||||||
|
f"seed_provider_credentials: "
|
||||||
|
f"{spec.provider}/{spec.account_key} already current — skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def _log_created(spec: _ProviderSpec) -> None:
|
||||||
|
hints: list[str] = []
|
||||||
|
if spec.session_key:
|
||||||
|
hints.append(f"session …{spec.session_key[-4:]}")
|
||||||
|
if spec.api_key:
|
||||||
|
hints.append(f"api_key …{spec.api_key[-4:]}")
|
||||||
|
print(
|
||||||
|
f"seed_provider_credentials: created "
|
||||||
|
f"{spec.provider}/{spec.account_key} ({', '.join(hints) or 'base_url only'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_updated(spec: _ProviderSpec) -> None:
|
||||||
|
hints: list[str] = []
|
||||||
|
if spec.session_key:
|
||||||
|
hints.append(f"session …{spec.session_key[-4:]}")
|
||||||
|
if spec.api_key:
|
||||||
|
hints.append(f"api_key …{spec.api_key[-4:]}")
|
||||||
|
print(
|
||||||
|
f"seed_provider_credentials: updated "
|
||||||
|
f"{spec.provider}/{spec.account_key} ({', '.join(hints) or 'base_url only'})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
WATCH_INTERVAL_SECONDS = 60 # re-check credential files every 60 s
|
||||||
|
|
||||||
|
|
||||||
|
def _credential_file_paths() -> list[str]:
|
||||||
|
"""Return the resolved paths for all watched credential files."""
|
||||||
|
claude_path = os.environ.get("CLAUDE_CREDENTIALS_PATH", "").strip()
|
||||||
|
if not claude_path:
|
||||||
|
claude_path = os.path.join(os.path.expanduser("~"), ".claude", ".credentials.json")
|
||||||
|
codex_path = os.environ.get("CODEX_CREDENTIALS_PATH", "").strip()
|
||||||
|
if not codex_path:
|
||||||
|
codex_path = os.path.join(os.path.expanduser("~"), ".codex", "auth.json")
|
||||||
|
return [claude_path, codex_path]
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_mtimes() -> dict[str, float]:
|
||||||
|
"""Return {path: mtime} for each watched file, 0.0 if missing."""
|
||||||
|
result: dict[str, float] = {}
|
||||||
|
for path in _credential_file_paths():
|
||||||
|
try:
|
||||||
|
result[path] = os.stat(path).st_mtime
|
||||||
|
except OSError:
|
||||||
|
result[path] = 0.0
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def watch(*, interval: int = WATCH_INTERVAL_SECONDS) -> None:
|
||||||
|
"""Background task: re-seed whenever a credential file changes.
|
||||||
|
|
||||||
|
Polls file mtimes every `interval` seconds (default 60). Only re-seeds
|
||||||
|
when at least one file's mtime has changed — no DB or file-read overhead
|
||||||
|
on quiet cycles.
|
||||||
|
|
||||||
|
Designed to be run as a long-lived asyncio task inside the FastAPI
|
||||||
|
lifespan. Exits cleanly on CancelledError.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
last_mtimes = _snapshot_mtimes()
|
||||||
|
logger.info(
|
||||||
|
"provider_credentials.watcher started interval=%ds paths=%s",
|
||||||
|
interval,
|
||||||
|
list(last_mtimes.keys()),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
current = _snapshot_mtimes()
|
||||||
|
changed_paths = [p for p, mtime in current.items() if mtime != last_mtimes.get(p)]
|
||||||
|
if not changed_paths:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for path in changed_paths:
|
||||||
|
prev = last_mtimes.get(path, 0.0)
|
||||||
|
curr = current[path]
|
||||||
|
if curr == 0.0:
|
||||||
|
logger.info("provider_credentials.watcher file_removed path=%s", path)
|
||||||
|
elif prev == 0.0:
|
||||||
|
logger.info("provider_credentials.watcher file_appeared path=%s", path)
|
||||||
|
else:
|
||||||
|
logger.info("provider_credentials.watcher file_changed path=%s", path)
|
||||||
|
|
||||||
|
last_mtimes = current
|
||||||
|
|
||||||
|
try:
|
||||||
|
n = await seed(verbose=False)
|
||||||
|
if n:
|
||||||
|
logger.info("provider_credentials.watcher reseeded count=%d", n)
|
||||||
|
else:
|
||||||
|
logger.debug("provider_credentials.watcher reseed no_changes")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("provider_credentials.watcher reseed_failed error=%s", exc)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("provider_credentials.watcher stopped")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Seed or watch local AI provider credentials.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--watch",
|
||||||
|
action="store_true",
|
||||||
|
help=f"Run continuously, re-seeding every {WATCH_INTERVAL_SECONDS}s when files change.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--interval",
|
||||||
|
type=int,
|
||||||
|
default=WATCH_INTERVAL_SECONDS,
|
||||||
|
metavar="SECONDS",
|
||||||
|
help=f"Poll interval in seconds (default: {WATCH_INTERVAL_SECONDS}).",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.watch:
|
||||||
|
print(f"seed_provider_credentials: watching every {args.interval}s — Ctrl-C to stop")
|
||||||
|
asyncio.run(watch(interval=args.interval))
|
||||||
|
else:
|
||||||
|
result = asyncio.run(seed(verbose=True))
|
||||||
|
print(f"seed_provider_credentials: done — {result} row(s) affected")
|
||||||
|
|
@ -48,6 +48,11 @@ services:
|
||||||
CLAUDE_CREDENTIALS_PATH: /run/secrets/claude_credentials
|
CLAUDE_CREDENTIALS_PATH: /run/secrets/claude_credentials
|
||||||
# Codex CLI credentials — read-only mount for ChatGPT subscription usage auto-detection.
|
# Codex CLI credentials — read-only mount for ChatGPT subscription usage auto-detection.
|
||||||
CODEX_CREDENTIALS_PATH: /run/secrets/codex_credentials
|
CODEX_CREDENTIALS_PATH: /run/secrets/codex_credentials
|
||||||
|
# AI provider API keys — seeded into provider_credentials on boot (optional).
|
||||||
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
|
ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||||
|
OPENAI_BASE_URL: ${OPENAI_BASE_URL:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ${CLAUDE_CREDENTIALS_FILE:-/home/kaspa/.claude/.credentials.json}:/run/secrets/claude_credentials:ro
|
- ${CLAUDE_CREDENTIALS_FILE:-/home/kaspa/.claude/.credentials.json}:/run/secrets/claude_credentials:ro
|
||||||
- ${CODEX_CREDENTIALS_FILE:-/home/kaspa/.codex/auth.json}:/run/secrets/codex_credentials:ro
|
- ${CODEX_CREDENTIALS_FILE:-/home/kaspa/.codex/auth.json}:/run/secrets/codex_credentials:ro
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue