diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 055945f4..1a1bab99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: timeout-minutes: 70 steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Select Xcode 26.1.1 (if present) or fallback to default run: | diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 81b52ead..b9ad64bd 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: inputs: tag: - description: "Release tag to upload assets to (for example, v0.24)." + description: "Release tag/version to package for manual artifact builds; manual runs do not publish." required: false type: string @@ -69,6 +69,20 @@ jobs: uname -m swift --version + - name: Validate release tag + if: github.event_name == 'release' || inputs.tag != '' + shell: bash + run: | + set -euo pipefail + if [[ -z "$RELEASE_TAG" ]]; then + echo "Missing release tag." >&2 + exit 1 + fi + if [[ ! "$RELEASE_TAG" =~ ^v[0-9A-Za-z._-]+$ ]]; then + echo "Invalid release tag: $RELEASE_TAG" >&2 + exit 1 + fi + - name: Install Swift 6.2.1 via swiftly if: matrix.platform == 'linux' shell: bash @@ -211,7 +225,7 @@ jobs: echo "asset=$ASSET" >> "$GITHUB_OUTPUT" - name: Upload release assets - if: github.event_name == 'release' || inputs.tag != '' + if: github.event_name == 'release' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash @@ -223,7 +237,7 @@ jobs: gh release upload "$TAG" "$OUT_DIR/$ASSET" "$OUT_DIR/$ASSET.sha256" --clobber - name: Upload workflow artifact (manual runs) - if: github.event_name != 'release' && inputs.tag == '' + if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@v6 with: name: codexbar-cli-${{ matrix.name }} @@ -234,24 +248,32 @@ jobs: update-homebrew-tap: runs-on: ubuntu-latest needs: build-cli - if: github.event_name == 'release' || inputs.tag != '' + if: github.event_name == 'release' steps: - name: Resolve release tag id: release + env: + RELEASE_TAG: ${{ github.ref_name }} shell: bash run: | set -euo pipefail - tag="${{ inputs.tag || github.ref_name }}" + tag="${RELEASE_TAG}" if [[ -z "$tag" ]]; then echo "Missing release tag." >&2 exit 1 fi + if [[ ! "$tag" =~ ^v[0-9A-Za-z._-]+$ ]]; then + echo "Invalid release tag: $tag" >&2 + exit 1 + fi echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "request_id=codexbar-${tag}-${GITHUB_RUN_ID}" >> "$GITHUB_OUTPUT" - name: Dispatch tap update env: GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + REQUEST_ID: ${{ steps.release.outputs.request_id }} shell: bash run: | set -euo pipefail @@ -260,13 +282,13 @@ jobs: if gh workflow run update-formula.yml \ --repo steipete/homebrew-tap \ -f formula=codexbar \ - -f tag="${{ steps.release.outputs.tag }}" \ + -f tag="$RELEASE_TAG" \ -f repository=steipete/CodexBar \ -f artifact_template='CodexBarCLI-{tag}-{target}.tar.gz' \ -f target_aliases='darwin_arm64=macos-arm64,darwin_amd64=macos-x86_64,linux_arm64=linux-aarch64,linux_amd64=linux-x86_64' \ -f cask=codexbar \ -f cask_artifact='CodexBar-macos-universal-{version}.zip' \ - -f request_id="${{ steps.release.outputs.request_id }}"; then + -f request_id="$REQUEST_ID"; then exit 0 fi if [[ "$attempt" -eq 6 ]]; then @@ -279,6 +301,7 @@ jobs: - name: Wait for tap update env: GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + REQUEST_ID: ${{ steps.release.outputs.request_id }} shell: bash run: | set -euo pipefail @@ -288,7 +311,7 @@ jobs: --repo steipete/homebrew-tap \ --workflow update-formula.yml \ --json databaseId,displayTitle \ - --jq '.[] | select(.displayTitle | contains("${{ steps.release.outputs.request_id }}")) | .databaseId' 2>/tmp/codexbar-tap-run-list.err \ + --jq '.[] | select(.displayTitle | contains(env.REQUEST_ID)) | .databaseId' 2>/tmp/codexbar-tap-run-list.err \ | head -n1 || true )" if [[ -n "$run_id" ]]; then diff --git a/AGENTS.md b/AGENTS.md index dd08f4cc..a2a1cf46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,12 @@ This is the complete development workflow for any AI agent working on CodexBar M --- +## Mac Upstream Testing Guardrails + +- Never run checks or ad-hoc validation that can display macOS Keychain prompts unless explicitly requested. +- Prefer parser, provider, and state/model tests over live app-bundle flows when behavior is CLI-testable. +- Use `KeychainNoUIQuery` or stubs for credential paths; do not touch real provider accounts during routine validation. + ## Development Lifecycle Every feature or fix follows these 7 steps in order: diff --git a/CHANGELOG.md b/CHANGELOG.md index af9bb7fa..b1b7bdab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.32.1.1 (Mobile 1.9.0 · build 76.1) — 2026-05-31 — upstream v0.32.1 sync + +Syncs the Mac app to upstream v0.32.1 while keeping the paired iOS companion at 1.9.0. This release folds in upstream v0.29.1 through v0.32.1. + +### Upstream highlights + +- Claude OAuth now keeps Claude CLI-owned refresh tokens delegated to Claude Code when CLI storage is present, preventing forced re-login after refresh-token rotation (#1161, #1239). +- Claude usage refreshes preserve cached credentials during rate limits and transient unauthorized responses. +- Provider security is tightened by requiring HTTPS before reattaching imported cookies on redirects. +- Menu-bar startup and open-menu refresh work is deferred/coalesced to reduce focus freezes, keychain prompts, and WebKit CPU spikes. +- Upstream provider/UI additions include provider search, Spark quota lanes, DeepSeek web-session cost summaries, Alibaba Token Plan endpoint updates, and localization updates. + +### Compatibility + +- iPhone companion remains iOS 1.9.0. No CloudKit schema deploy is required for this sync by itself. +- The Sparkle appcast is not updated in this merge commit; it should be regenerated only after the signed release artifacts are created. + ## 0.29.0.1 (Mobile 1.9.0 · build 68.1) — 2026-05-27 — upstream v0.29.0 + iOS 1.9.0 Syncs the Mac app to upstream CodexBar v0.29.0 and ships the paired iOS 1.9.0 companion. Three new providers — Azure OpenAI, Alibaba Token Plan (Bailian), and T3 Chat — plus the upstream v0.28.0 + v0.29.0 fixes. diff --git a/Package.swift b/Package.swift index dd542a62..0bfea99c 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,23 @@ let package = Package( platforms: [ .macOS(.v14), ], + products: { + var products: [Product] = [ + .library(name: "CodexBarCore", targets: ["CodexBarCore"]), + .executable(name: "CodexBarCLI", targets: ["CodexBarCLI"]), + ] + + #if os(macOS) + products.append(contentsOf: [ + .executable(name: "CodexBar", targets: ["CodexBar"]), + .executable(name: "CodexBarClaudeWatchdog", targets: ["CodexBarClaudeWatchdog"]), + .executable(name: "CodexBarWidget", targets: ["CodexBarWidget"]), + .executable(name: "CodexBarClaudeWebProbe", targets: ["CodexBarClaudeWebProbe"]), + ]) + #endif + + return products + }(), dependencies: [ .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1"), .package(url: "https://github.com/steipete/Commander", from: "0.2.1"), diff --git a/README.md b/README.md index aa7fbbc2..1db3280b 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Download: ### Homebrew ```bash -brew install --cask steipete/tap/codexbar +brew install --cask codexbar ``` ### Linux (CLI only) @@ -84,6 +84,10 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [OpenAI](docs/openai.md) — Admin API key usage/cost graphs with legacy credit-balance fallback. - [Claude](docs/claude.md) — OAuth API, browser cookies, or CLI PTY fallback; session and weekly usage where available. - [Cursor](docs/cursor.md) — Browser session cookies for plan + usage + billing resets. +- [OpenCode](docs/opencode.md) — Browser cookies for workspace subscription usage. +- [OpenCode Go](docs/opencode.md) — Browser cookies for Go usage windows. +- [Alibaba Coding Plan](docs/alibaba-coding-plan.md) — Web cookies or API key for coding-plan quotas. +- [Alibaba Token Plan](docs/alibaba-token-plan.md) — Bailian browser/manual cookies for token-plan credits. - [Gemini](docs/gemini.md) — OAuth-backed quota API using Gemini CLI credentials (no browser cookies). - [Antigravity](docs/antigravity.md) — Local language server probe (experimental); no external auth. - [Droid](docs/factory.md) — Browser cookies + WorkOS token flows for Factory usage + billing. @@ -113,7 +117,7 @@ See [CLI configuration](docs/cli-configuration.md) for the full flow. - [Crof](docs/crof.md) — API key for dollar credit balance and request quota tracking. - [Command Code](docs/command-code.md) — Browser cookies for monthly USD credits from Command Code billing. - [StepFun](docs/stepfun.md) — Username + password login for Step Plan rate limits (5‑hour + weekly windows) and subscription plan name. -- [AWS Bedrock](docs/bedrock.md) — AWS credentials for Cost Explorer usage and monthly budget tracking. +- [AWS Bedrock](docs/bedrock.md) — AWS access keys or a named AWS profile (SSO/assume-role via the AWS CLI) for Cost Explorer usage and monthly budget tracking. - [Grok](docs/grok.md) — Grok CLI billing RPC plus grok.com browser-session fallback. - [GroqCloud](docs/groqcloud.md) — API key for Enterprise Prometheus request/token/cache-hit metrics. - [LLM Proxy](docs/llm-proxy.md) — API key + base URL for aggregate proxy quota stats and provider breakdowns. @@ -205,6 +209,8 @@ Dev loop: ## Linux desktop integration? - [codexbar-waybar](https://github.com/Marouan-chak/codexbar-waybar) — Waybar custom module + GTK4 popover for Hyprland / Sway / other Wayland compositors, built on top of the bundled Linux CLI. +- [Codexbar GNOME](https://extensions.gnome.org/extension/9841/codexbar/) — GNOME Shell extension that brings CodexBar usage into the desktop panel. +- [noctalia-codex-usage](https://github.com/rayoplateado/noctalia-codex-usage) — Noctalia/Quickshell plugin that shows Codex 5-hour and weekly usage limits, built on top of the bundled Linux CLI. ## Credits Inspired by [ccusage](https://github.com/ryoppippi/ccusage) (MIT), specifically the cost usage tracking. diff --git a/Scripts/ci_swift_test_by_suite.py b/Scripts/ci_swift_test_by_suite.py index 55e28ea0..81ac0f8f 100755 --- a/Scripts/ci_swift_test_by_suite.py +++ b/Scripts/ci_swift_test_by_suite.py @@ -38,7 +38,17 @@ def run_command(command: list[str], timeout: int | None = None) -> int: def swift_test_list() -> list[str]: - result = subprocess.run(["swift", "test", "list"], check=True, capture_output=True, text=True) + result = subprocess.run(["swift", "test", "list"], check=False, capture_output=True, text=True) + if result.returncode != 0: + print("swift test list failed", file=sys.stderr, flush=True) + if result.stdout: + print("stdout:", file=sys.stderr, flush=True) + print(result.stdout, file=sys.stderr, flush=True) + if result.stderr: + print("stderr:", file=sys.stderr, flush=True) + print(result.stderr, file=sys.stderr, flush=True) + returncode = result.returncode + raise subprocess.CalledProcessError(returncode, result.args, output=result.stdout, stderr=result.stderr) suites: set[str] = set() for line in result.stdout.splitlines(): if "/" not in line: diff --git a/Scripts/mac-release b/Scripts/mac-release index aa9d637a..b2a0178e 100755 --- a/Scripts/mac-release +++ b/Scripts/mac-release @@ -10,8 +10,8 @@ if [[ -n "${MAC_RELEASE_TOOL:-}" ]]; then fi for candidate in \ - "$ROOT/../agent-scripts/skills/mac-app-release/scripts/mac-release" \ - "$HOME/Projects/agent-scripts/skills/mac-app-release/scripts/mac-release"; do + "$ROOT/../agent-scripts/skills/release-mac-app/scripts/mac-release" \ + "$HOME/Projects/agent-scripts/skills/release-mac-app/scripts/mac-release"; do if [[ -x "$candidate" ]]; then exec "$candidate" "$@" fi diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 1ea1c937..7da0c446 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -99,160 +99,6 @@ path.write_text(text) PY } -generate_widget_appintents_metadata() { - local widget_resources_dir="$1" - local metadata_mode="${CODEXBAR_WIDGET_METADATA_MODE:-}" - local xcode_conf - local host_arch - local derived_dir - local build_dir - local object_dir - local source_file_list - local const_values_list - local dependency_metadata - local static_dependency_metadata - local appintents_tool - local sdk_root - local swiftc_path - local toolchain_dir - local xcode_version - - if [[ -z "$metadata_mode" ]]; then - if [[ "${SIGNING_MODE:-}" == "adhoc" || "$LOWER_CONF" == "debug" ]]; then - metadata_mode="skip" - else - metadata_mode="required" - fi - fi - - if [[ "$metadata_mode" == "skip" ]]; then - echo "Skipping widget App Intents metadata (CODEXBAR_WIDGET_METADATA_MODE=skip)." - return 0 - fi - - widget_metadata_warn_or_fail() { - local message="$1" - if [[ "$metadata_mode" == "required" ]]; then - echo "ERROR: ${message}" >&2 - exit 1 - fi - echo "WARN: ${message}; continuing without widget App Intents metadata." >&2 - return 0 - } - - xcode_conf="Release" - if [[ "$LOWER_CONF" == "debug" ]]; then - xcode_conf="Debug" - fi - - host_arch=$(uname -m) - derived_dir="$ROOT/.build/xcode-widget-metadata-${LOWER_CONF}" - build_dir="$derived_dir/Build/Intermediates.noindex/CodexBar.build/${xcode_conf}/CodexBarWidget.build" - object_dir="$build_dir/Objects-normal/${host_arch}" - source_file_list="$object_dir/CodexBarWidget.SwiftFileList" - const_values_list="$object_dir/CodexBarWidget.SwiftConstValuesFileList" - dependency_metadata="$build_dir/CodexBarWidget.DependencyMetadataFileList" - static_dependency_metadata="$build_dir/CodexBarWidget.DependencyStaticMetadataFileList" - - appintents_tool=$(xcrun --find appintentsmetadataprocessor) - sdk_root=$(xcrun --sdk macosx --show-sdk-path) - swiftc_path=$(xcrun --find swiftc) - toolchain_dir=$(dirname "$(dirname "$(dirname "$swiftc_path")")") - xcode_version=$(xcodebuild -version | awk '/Build version/ { print $3 }') - - if [[ "${CODEXBAR_FORCE_WIDGET_METADATA_CLEAN:-0}" == "1" ]]; then - rm -rf "$derived_dir" - fi - mkdir -p "$derived_dir" - local xcodebuild_log="$derived_dir/xcodebuild.log" - local timeout_seconds="${CODEXBAR_WIDGET_METADATA_TIMEOUT_SECONDS:-}" - if [[ -z "$timeout_seconds" ]]; then - if [[ "$metadata_mode" == "required" ]]; then - timeout_seconds=600 - else - timeout_seconds=45 - fi - fi - echo "Generating widget App Intents metadata (${metadata_mode}, timeout ${timeout_seconds}s)." - xcodebuild \ - -workspace "$ROOT/.swiftpm/xcode/package.xcworkspace" \ - -scheme CodexBarWidget \ - -configuration "$xcode_conf" \ - -destination "platform=macOS,arch=${host_arch}" \ - -derivedDataPath "$derived_dir" \ - -skipPackageUpdates \ - -disableAutomaticPackageResolution \ - -skipMacroValidation \ - -skipPackagePluginValidation \ - build >"$xcodebuild_log" 2>&1 & - local xcodebuild_pid=$! - local elapsed=0 - while kill -0 "$xcodebuild_pid" 2>/dev/null; do - if [[ "$elapsed" -ge "$timeout_seconds" ]]; then - kill "$xcodebuild_pid" 2>/dev/null || true - wait "$xcodebuild_pid" 2>/dev/null || true - tail -40 "$xcodebuild_log" >&2 || true - if [[ "${CODEXBAR_ALLOW_MISSING_WIDGET_METADATA:-0}" == "1" ]]; then - echo "WARN: Timed out generating widget App Intents metadata after ${timeout_seconds}s; continuing without it." >&2 - return 0 - fi - widget_metadata_warn_or_fail "Timed out generating widget App Intents metadata after ${timeout_seconds}s" - return 0 - fi - sleep 5 - elapsed=$((elapsed + 5)) - if (( elapsed > 0 && elapsed % 30 == 0 )); then - echo "Still generating widget App Intents metadata (${elapsed}s)..." - fi - done - if ! wait "$xcodebuild_pid"; then - tail -80 "$xcodebuild_log" >&2 || true - widget_metadata_warn_or_fail "Failed to build CodexBarWidget metadata inputs" - return 0 - fi - - local xcode_metadata_dir="$derived_dir/Build/Products/${xcode_conf}/CodexBarWidget.appintents/Metadata.appintents" - if [[ -f "$xcode_metadata_dir/extract.actionsdata" ]]; then - rm -rf "$widget_resources_dir/Metadata.appintents" - mkdir -p "$widget_resources_dir" - cp -R "$xcode_metadata_dir" "$widget_resources_dir/" - return 0 - fi - - if [[ ! -f "$source_file_list" ]]; then - widget_metadata_warn_or_fail "Missing App Intents metadata inputs for CodexBarWidget" - return 0 - fi - - find "$object_dir" -name '*.swiftconstvalues' | sort > "$const_values_list" - if [[ ! -s "$const_values_list" ]]; then - widget_metadata_warn_or_fail "Missing App Intents const-values outputs for CodexBarWidget" - return 0 - fi - rm -rf "$widget_resources_dir/Metadata.appintents" - mkdir -p "$widget_resources_dir" - - "$appintents_tool" \ - --output "$widget_resources_dir" \ - --toolchain-dir "$toolchain_dir" \ - --module-name CodexBarWidget \ - --sdk-root "$sdk_root" \ - --xcode-version "$xcode_version" \ - --platform-family macOS \ - --deployment-target 14.0 \ - --target-triple "${host_arch}-apple-macos14.0" \ - --source-file-list "$source_file_list" \ - --swift-const-vals-list "$const_values_list" \ - --metadata-file-list "$dependency_metadata" \ - --static-metadata-file-list "$static_dependency_metadata" \ - --force >/dev/null - - if [[ ! -f "$widget_resources_dir/Metadata.appintents/extract.actionsdata" ]]; then - widget_metadata_warn_or_fail "Failed to generate App Intents metadata for CodexBarWidget" - return 0 - fi -} - KEYBOARD_SHORTCUTS_UTIL="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift" if [[ ! -f "$KEYBOARD_SHORTCUTS_UTIL" ]]; then swift build -c "$CONF" --arch "${ARCH_LIST[0]}" @@ -471,6 +317,92 @@ install_binary() { verify_binary_arches "$dest" "${ARCH_LIST[@]}" } +ensure_widget_extension_project() { + local spec="$ROOT/WidgetExtension/project.yml" + local project_dir="$ROOT/WidgetExtension/CodexBarWidgetExtension.xcodeproj" + if command -v xcodegen >/dev/null 2>&1; then + xcodegen generate --spec "$spec" --project "$ROOT/WidgetExtension" --quiet + elif [[ ! -f "$project_dir/project.pbxproj" ]]; then + echo "ERROR: Missing ${project_dir}; install xcodegen or restore the generated project." >&2 + exit 1 + fi +} + +build_widget_extension() { + local xcode_conf="Release" + if [[ "$LOWER_CONF" == "debug" ]]; then + xcode_conf="Debug" + fi + + ensure_widget_extension_project + + local derived_dir="$ROOT/.build/xcode-widget-extension-${LOWER_CONF}" + local project_dir="$ROOT/WidgetExtension/CodexBarWidgetExtension.xcodeproj" + local build_log="$derived_dir/xcodebuild.log" + local timeout_seconds="${CODEXBAR_WIDGET_EXTENSION_TIMEOUT_SECONDS:-900}" + local archs="${ARCH_LIST[*]}" + + mkdir -p "$derived_dir" + echo "Building CodexBarWidget Xcode extension (${xcode_conf}, ${archs})." >&2 + xcodebuild \ + -project "$project_dir" \ + -scheme CodexBarWidgetExtension \ + -configuration "$xcode_conf" \ + -destination "generic/platform=macOS" \ + -derivedDataPath "$derived_dir" \ + -skipPackageUpdates \ + -disableAutomaticPackageResolution \ + -skipMacroValidation \ + -skipPackagePluginValidation \ + CODEXBAR_WIDGET_BUNDLE_ID="$WIDGET_BUNDLE_ID" \ + CODEXBAR_TEAM_ID="$APP_TEAM_ID" \ + MARKETING_VERSION="$MARKETING_VERSION" \ + CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ + CODE_SIGNING_ALLOWED=NO \ + ARCHS="$archs" \ + ONLY_ACTIVE_ARCH=NO \ + build >"$build_log" 2>&1 & + + local xcodebuild_pid=$! + local elapsed=0 + while kill -0 "$xcodebuild_pid" 2>/dev/null; do + if [[ "$elapsed" -ge "$timeout_seconds" ]]; then + kill "$xcodebuild_pid" 2>/dev/null || true + wait "$xcodebuild_pid" 2>/dev/null || true + tail -80 "$build_log" >&2 || true + echo "ERROR: Timed out building CodexBarWidget extension after ${timeout_seconds}s" >&2 + exit 1 + fi + sleep 5 + elapsed=$((elapsed + 5)) + if (( elapsed > 0 && elapsed % 60 == 0 )); then + echo "Still building CodexBarWidget extension (${elapsed}s)..." >&2 + fi + done + if ! wait "$xcodebuild_pid"; then + tail -120 "$build_log" >&2 || true + echo "ERROR: Failed to build CodexBarWidget extension" >&2 + exit 1 + fi + + local appex="$derived_dir/Build/Products/${xcode_conf}/CodexBarWidget.appex" + if [[ ! -f "$appex/Contents/MacOS/CodexBarWidget" ]]; then + echo "ERROR: Missing Xcode-built CodexBarWidget.appex at ${appex}" >&2 + exit 1 + fi + echo "$appex" +} + +install_widget_extension() { + local src_appex + src_appex="$(build_widget_extension)" + local widget_app="$APP/Contents/PlugIns/CodexBarWidget.appex" + rm -rf "$widget_app" + mkdir -p "$APP/Contents/PlugIns" + cp -R "$src_appex" "$widget_app" + verify_binary_arches "$widget_app/Contents/MacOS/CodexBarWidget" "${ARCH_LIST[@]}" +} + install_binary "CodexBar" "$APP/Contents/MacOS/CodexBar" # Ship CodexBarCLI alongside the app for easy symlinking. if [[ -n "$(resolve_binary_path "CodexBarCLI" "${ARCH_LIST[0]}")" ]]; then @@ -480,34 +412,7 @@ fi if [[ -n "$(resolve_binary_path "CodexBarClaudeWatchdog" "${ARCH_LIST[0]}")" ]]; then install_binary "CodexBarClaudeWatchdog" "$APP/Contents/Helpers/CodexBarClaudeWatchdog" fi -if [[ -n "$(resolve_binary_path "CodexBarWidget" "${ARCH_LIST[0]}")" ]]; then - WIDGET_APP="$APP/Contents/PlugIns/CodexBarWidget.appex" - mkdir -p "$WIDGET_APP/Contents/MacOS" "$WIDGET_APP/Contents/Resources" - cat > "$WIDGET_APP/Contents/Info.plist" < - - - - CFBundleNameCodexBarWidget - CFBundleDisplayNameCodexBar - CFBundleIdentifier${WIDGET_BUNDLE_ID} - CFBundleExecutableCodexBarWidget - CFBundlePackageTypeXPC! - CFBundleShortVersionString${MARKETING_VERSION} - CFBundleVersion${BUILD_NUMBER}.${MOBILE_VERSION} - LSMinimumSystemVersion14.0 - CodexBarTeamID${APP_TEAM_ID} - NSExtension - - NSExtensionPointIdentifiercom.apple.widgetkit-extension - NSExtensionPrincipalClassCodexBarWidget.CodexBarWidgetBundle - - - -PLIST - install_binary "CodexBarWidget" "$WIDGET_APP/Contents/MacOS/CodexBarWidget" - generate_widget_appintents_metadata "$WIDGET_APP/Contents/Resources" -fi +install_widget_extension # Embed Sparkle.framework if [[ -d ".build/$CONF/Sparkle.framework" ]]; then COPYFILE_DISABLE=1 cp -R ".build/$CONF/Sparkle.framework" "$APP/Contents/Frameworks/" diff --git a/Scripts/sign-and-notarize.sh b/Scripts/sign-and-notarize.sh index 47a40456..1e4a80b2 100755 --- a/Scripts/sign-and-notarize.sh +++ b/Scripts/sign-and-notarize.sh @@ -36,16 +36,25 @@ if [[ $(printf "%s\n" "$key_lines" | wc -l) -ne 1 ]]; then exit 1 fi +NOTARIZATION_TEMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/codexbar-notarize.XXXXXX") +chmod 700 "$NOTARIZATION_TEMP_DIR" +API_KEY_PATH="$NOTARIZATION_TEMP_DIR/codexbar-api-key.p8" +NOTARIZATION_ZIP="$NOTARIZATION_TEMP_DIR/${APP_NAME}Notarize.zip" +trap 'rm -rf "$NOTARIZATION_TEMP_DIR" "$RELEASE_STAGE_DIR"' EXIT + if [[ -n "${APP_STORE_CONNECT_API_KEY_FILE:-}" ]]; then if [[ ! -f "$APP_STORE_CONNECT_API_KEY_FILE" ]]; then echo "App Store Connect API key file not found: $APP_STORE_CONNECT_API_KEY_FILE" >&2 exit 1 fi - cp "$APP_STORE_CONNECT_API_KEY_FILE" /tmp/codexbar-api-key.p8 + cp "$APP_STORE_CONNECT_API_KEY_FILE" "$API_KEY_PATH" else - echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/codexbar-api-key.p8 + ( + umask 077 + printf '%s' "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > "$API_KEY_PATH" + ) fi -trap 'rm -f /tmp/codexbar-api-key.p8 /tmp/${APP_NAME}Notarize.zip; rm -rf "$RELEASE_STAGE_DIR"' EXIT +chmod 600 "$API_KEY_PATH" # Allow building a universal binary if ARCHES is provided; default to universal (arm64 + x86_64). ARCHES_VALUE=${ARCHES:-"arm64 x86_64"} @@ -82,11 +91,11 @@ codesign --force --timestamp --options runtime --sign "$APP_IDENTITY" \ "$APP_BUNDLE" DITTO_BIN=${DITTO_BIN:-/usr/bin/ditto} -"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "/tmp/${APP_NAME}Notarize.zip" +"$DITTO_BIN" --norsrc -c -k --keepParent "$APP_BUNDLE" "$NOTARIZATION_ZIP" echo "Submitting for notarization" -xcrun notarytool submit "/tmp/${APP_NAME}Notarize.zip" \ - --key /tmp/codexbar-api-key.p8 \ +xcrun notarytool submit "$NOTARIZATION_ZIP" \ + --key "$API_KEY_PATH" \ --key-id "$APP_STORE_CONNECT_KEY_ID" \ --issuer "$APP_STORE_CONNECT_ISSUER_ID" \ --wait diff --git a/Sources/CodexBar/CodexAccountPromotionCoordinator.swift b/Sources/CodexBar/CodexAccountPromotionCoordinator.swift index 900ce48b..c22699be 100644 --- a/Sources/CodexBar/CodexAccountPromotionCoordinator.swift +++ b/Sources/CodexBar/CodexAccountPromotionCoordinator.swift @@ -73,36 +73,37 @@ final class CodexAccountPromotionCoordinator { private static func interactionBlockedError() -> CodexSystemAccountPromotionUserFacingError { CodexSystemAccountPromotionUserFacingError( - title: "Could not switch system account", - message: "Finish the current managed account change before switching the system account.") + title: L("Could not switch system account"), + message: L("Finish the current managed account change before switching the system account.")) } static func mapUserFacingError(_ error: Error) -> CodexSystemAccountPromotionUserFacingError { - let title = "Could not switch system account" + let title = L("Could not switch system account") if let error = error as? CodexAccountPromotionError { let message = switch error { case .targetManagedAccountNotFound: - "That account is no longer available in CodexBar. Refresh the account list and try again." + L("That account is no longer available in CodexBar. Refresh the account list and try again.") case .targetManagedAccountAuthMissing: - "CodexBar could not find saved auth for that account. Re-authenticate it and try again." + L("CodexBar could not find saved auth for that account. Re-authenticate it and try again.") case .targetManagedAccountAuthUnreadable: - "CodexBar could not read saved auth for that account. Re-authenticate it and try again." + L("CodexBar could not read saved auth for that account. Re-authenticate it and try again.") case .liveAccountUnreadable: - "CodexBar could not read the current system account on this Mac." + L("CodexBar could not read the current system account on this Mac.") case .liveAccountMissingIdentityForPreservation: - "CodexBar could not safely preserve the current system account before switching." + L("CodexBar could not safely preserve the current system account before switching.") case .liveAccountAPIKeyOnlyUnsupported: - "CodexBar can't replace a system account that is signed in with an API key only setup." + L("CodexBar can't replace a system account that is signed in with an API key only setup.") case .displacedLiveManagedAccountConflict: - "CodexBar found another managed account that already uses the current system account. " - + "Resolve the duplicate account before switching." + L( + "CodexBar found another managed account that already uses the current system account. " + + "Resolve the duplicate account before switching.") case .displacedLiveImportFailed: - "CodexBar could not save the current system account before switching." + L("CodexBar could not save the current system account before switching.") case .managedStoreCommitFailed: - "CodexBar could not update managed account storage." + L("CodexBar could not update managed account storage.") case .liveAuthSwapFailed: - "CodexBar could not replace the live Codex auth on this Mac." + L("CodexBar could not replace the live Codex auth on this Mac.") } return CodexSystemAccountPromotionUserFacingError(title: title, message: message) diff --git a/Sources/CodexBar/CodexLoginAlertPresentation.swift b/Sources/CodexBar/CodexLoginAlertPresentation.swift index 9f1e7965..30702f15 100644 --- a/Sources/CodexBar/CodexLoginAlertPresentation.swift +++ b/Sources/CodexBar/CodexLoginAlertPresentation.swift @@ -12,25 +12,31 @@ enum CodexLoginAlertPresentation { return nil case .missingBinary: return CodexLoginAlertInfo( - title: "Codex CLI not found", - message: "Install the Codex CLI (npm i -g @openai/codex) and try again.") + title: L("Codex CLI not found"), + message: L("Install the Codex CLI (npm i -g @openai/codex) and try again.")) case let .launchFailed(message): - return CodexLoginAlertInfo(title: "Could not start codex login", message: message) + return CodexLoginAlertInfo(title: L("Could not start codex login"), message: message) case .timedOut: return CodexLoginAlertInfo( - title: "Codex login timed out", + title: L("Codex login timed out"), message: self.trimmedOutput(result.output)) case let .failed(status): - let statusLine = "codex login exited with status \(status)." + let statusLine = String(format: L("codex login exited with status %d."), status) let message = self.trimmedOutput(result.output.isEmpty ? statusLine : result.output) - return CodexLoginAlertInfo(title: "Codex login failed", message: message) + return CodexLoginAlertInfo(title: L("Codex login failed"), message: message) } } + static func managedLoginFailureMessage(for result: CodexLoginRunner.Result) -> String { + let baseMessage = L("managed_login_failed") + guard let info = self.alertInfo(for: result) else { return baseMessage } + return "\(baseMessage)\n\n\(L("codex_login_output"))\n\(info.message)" + } + private static func trimmedOutput(_ text: String) -> String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let limit = 600 - if trimmed.isEmpty { return "No output captured." } + if trimmed.isEmpty { return L("No output captured.") } if trimmed.count <= limit { return trimmed } let idx = trimmed.index(trimmed.startIndex, offsetBy: limit) return "\(trimmed[.. String { diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 64a6539a..2aa0d327 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -45,9 +45,10 @@ struct CodexBarApp: App { let preferencesSelection = PreferencesSelection() let settings = SettingsStore() Self.applyLanguagePreference(from: settings) + configureUsageFormatterLocalizationProvider() let managedCodexAccountCoordinator = ManagedCodexAccountCoordinator() managedCodexAccountCoordinator.onManagedAccountsDidChange = { - _ = settings.persistResolvedCodexActiveSourceCorrectionIfNeeded() + _ = settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange() } _ = settings.persistResolvedCodexActiveSourceCorrectionIfNeeded() let fetcher = UsageFetcher() @@ -364,6 +365,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var managedCodexAccountCoordinator: ManagedCodexAccountCoordinator? private var codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator? private var hasInstalledWeeklyLimitResetObserver = false + var terminateActiveProcessesForAppShutdown: () -> Void = { + TTYCommandRunner.terminateActiveProcessesForAppShutdown() + } func configure(_ dependencies: Dependencies) { self.store = dependencies.store @@ -397,8 +401,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + self.statusController?.prepareForAppShutdown() self.confettiOverlayController.dismiss() - TTYCommandRunner.terminateActiveProcessesForAppShutdown() + self.dismissAppKitWindowsForShutdown() + self.terminateActiveProcessesForAppShutdown() } func runProviderLoginFlow(_ provider: UsageProvider) async { @@ -446,6 +452,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Bundle.main.url(forResource: "Icon-classic", withExtension: "icns") } + private func dismissAppKitWindowsForShutdown() { + guard let app = NSApp else { return } + for window in app.windows { + window.orderOut(nil) + } + } + private func ensureStatusController() { if self.statusController != nil { return } diff --git a/Sources/CodexBar/Config/CodexBarConfigMigrator.swift b/Sources/CodexBar/Config/CodexBarConfigMigrator.swift index 36f303a3..733e4198 100644 --- a/Sources/CodexBar/Config/CodexBarConfigMigrator.swift +++ b/Sources/CodexBar/Config/CodexBarConfigMigrator.swift @@ -54,14 +54,20 @@ struct CodexBarConfigMigrator { self.migrateLegacyAccounts(stores: stores, config: &config, state: &state) } + var didPersistUpdates = true if state.didUpdate { do { try configStore.save(config) } catch { + didPersistUpdates = false log.error("Failed to persist config: \(error)") } } + guard didPersistUpdates else { + return config.normalized() + } + if state.sawLegacySecrets || state.sawLegacyAccounts { let cleared = self.clearLegacyStores(stores: stores, sawAccounts: state.sawLegacyAccounts, log: log) if cleared { diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index 406e85ec..e1a35e28 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -11,11 +11,13 @@ struct CostHistoryChartMenuView: View { let date: Date let costUSD: Double let totalTokens: Int? + let requestCount: Int? - init(date: Date, costUSD: Double, totalTokens: Int?) { + init(date: Date, costUSD: Double, totalTokens: Int?, requestCount: Int?) { self.date = date self.costUSD = costUSD self.totalTokens = totalTokens + self.requestCount = requestCount self.id = "\(Int(date.timeIntervalSince1970))-\(costUSD)" } } @@ -36,7 +38,9 @@ struct CostHistoryChartMenuView: View { private let provider: UsageProvider private let daily: [DailyEntry] private let totalCostUSD: Double? + private let currencyCode: String private let historyDays: Int + private let windowLabel: String? private let width: CGFloat @State private var selectedDateKey: String? @@ -44,13 +48,17 @@ struct CostHistoryChartMenuView: View { provider: UsageProvider, daily: [DailyEntry], totalCostUSD: Double?, + currencyCode: String = "USD", historyDays: Int = 30, + windowLabel: String? = nil, width: CGFloat) { self.provider = provider self.daily = daily self.totalCostUSD = totalCostUSD + self.currencyCode = currencyCode self.historyDays = max(1, min(365, historyDays)) + self.windowLabel = windowLabel self.width = width } @@ -58,24 +66,24 @@ struct CostHistoryChartMenuView: View { let model = Self.makeModel(provider: self.provider, daily: self.daily) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { - Text("No cost history data.") + Text(L("No cost history data.")) .font(.footnote) .foregroundStyle(.secondary) - .accessibilityLabel("No cost history data available.") + .accessibilityLabel(L("No cost history data.")) } else { Chart { ForEach(model.points) { point in BarMark( - x: .value("Day", point.date, unit: .day), - y: .value("Cost", point.costUSD)) + x: .value(L("Day"), point.date, unit: .day), + y: .value(L("Cost"), point.costUSD)) .foregroundStyle(model.barColor) } if let peak = Self.peakPoint(model: model) { let capStart = max(peak.costUSD - Self.capHeight(maxValue: model.maxCostUSD), 0) BarMark( - x: .value("Day", peak.date, unit: .day), - yStart: .value("Cap start", capStart), - yEnd: .value("Cap end", peak.costUSD)) + x: .value(L("Day"), peak.date, unit: .day), + yStart: .value(L("Cap start"), capStart), + yEnd: .value(L("Cap end"), peak.costUSD)) .foregroundStyle(Color(nsColor: .systemYellow)) } } @@ -91,8 +99,11 @@ struct CostHistoryChartMenuView: View { } .chartLegend(.hidden) .frame(height: 130) - .accessibilityLabel("Cost history chart") - .accessibilityValue(model.points.isEmpty ? "No data" : "\(model.points.count) days of cost data") + .accessibilityLabel(L("Cost history chart")) + .accessibilityValue( + model.points.isEmpty + ? L("No data") + : String(format: L("%d days of cost data"), model.points.count)) .chartOverlay { proxy in GeometryReader { geo in ZStack(alignment: .topLeading) { @@ -171,7 +182,10 @@ struct CostHistoryChartMenuView: View { } if let total = self.totalCostUSD { - Text("Est. total (\(Self.windowLabel(days: self.historyDays))): \(UsageFormatter.usdString(total))") + Text(String( + format: L("Est. total (%@): %@"), + self.windowLabel ?? Self.windowLabel(days: self.historyDays), + self.costString(total))) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) @@ -205,8 +219,11 @@ struct CostHistoryChartMenuView: View { private static let expandedDetailRowHeight: CGFloat = 44 private static let detailSpacing: CGFloat = 6 - private static func windowLabel(days: Int) -> String { - days == 1 ? "today" : "\(days)d" + static func windowLabel(days: Int) -> String { + if days == 1 { + return L("Today") + } + return String(format: L("Last %d days"), days) } private static func detailRowHeight(for row: DetailRow) -> CGFloat { @@ -246,7 +263,11 @@ struct CostHistoryChartMenuView: View { for entry in sorted { guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } guard let date = self.dateFromDayKey(entry.date) else { continue } - let point = Point(date: date, costUSD: costUSD, totalTokens: entry.totalTokens) + let point = Point( + date: date, + costUSD: costUSD, + totalTokens: entry.totalTokens, + requestCount: entry.requestCount) points.append(point) pointsByKey[entry.date] = point entriesByKey[entry.date] = entry @@ -416,16 +437,19 @@ struct CostHistoryChartMenuView: View { let point = model.pointsByDateKey[key], let date = Self.dateFromDayKey(key) else { - return DetailContent(primary: "Hover a bar for details", rows: []) + return DetailContent(primary: L("Hover a bar for details"), rows: []) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) - let cost = UsageFormatter.usdString(point.costUSD) - let primary = if let tokens = point.totalTokens { - "\(dayLabel): \(cost) · \(UsageFormatter.tokenCountString(tokens)) tokens" - } else { - "\(dayLabel): \(cost)" + let cost = self.costString(point.costUSD) + var parts = [cost] + if let tokens = point.totalTokens { + parts.append("\(UsageFormatter.tokenCountString(tokens)) tokens") + } + if let requests = point.requestCount { + parts.append("\(UsageFormatter.tokenCountString(requests)) requests") } + let primary = "\(dayLabel): \(parts.joined(separator: " · "))" return DetailContent(primary: primary, rows: self.breakdownRows(key: key, model: model)) } @@ -466,20 +490,21 @@ struct CostHistoryChartMenuView: View { UsageFormatter.modelCostDetail( item.modelName, costUSD: item.costUSD, - totalTokens: item.totalTokens) + totalTokens: item.totalTokens, + currencyCode: self.currencyCode) } private func modelBreakdownModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? { var parts: [String] = [] if let standardCost = item.standardCostUSD { - var standardPart = "Std \(UsageFormatter.usdString(standardCost))" + var standardPart = "Std \(self.costString(standardCost))" if let standardTokens = item.standardTokens { standardPart += " · \(UsageFormatter.tokenCountString(standardTokens))" } parts.append(standardPart) } if let priorityCost = item.priorityCostUSD { - var priorityPart = "Fast \(UsageFormatter.usdString(priorityCost))" + var priorityPart = "Fast \(self.costString(priorityCost))" if let priorityTokens = item.priorityTokens { priorityPart += " · \(UsageFormatter.tokenCountString(priorityTokens))" } @@ -489,6 +514,10 @@ struct CostHistoryChartMenuView: View { return parts.joined(separator: " / ") } + private func costString(_ value: Double) -> String { + UsageFormatter.currencyString(value, currencyCode: self.currencyCode) + } + private static func breakdownAccentOpacity(for index: Int) -> Double { let opacity = 0.75 - (Double(index) * 0.12) return max(0.3, opacity) diff --git a/Sources/CodexBar/CreditsHistoryChartMenuView.swift b/Sources/CodexBar/CreditsHistoryChartMenuView.swift index a746251b..e75521eb 100644 --- a/Sources/CodexBar/CreditsHistoryChartMenuView.swift +++ b/Sources/CodexBar/CreditsHistoryChartMenuView.swift @@ -29,24 +29,24 @@ struct CreditsHistoryChartMenuView: View { let model = Self.makeModel(from: self.breakdown) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { - Text("No credits history data.") + Text(L("No credits history data.")) .font(.footnote) .foregroundStyle(.secondary) - .accessibilityLabel("No credits history data available.") + .accessibilityLabel(L("No credits history data available.")) } else { Chart { ForEach(model.points) { point in BarMark( - x: .value("Day", point.date, unit: .day), - y: .value("Credits used", point.creditsUsed)) + x: .value(L("Day"), point.date, unit: .day), + y: .value(L("Credits used"), point.creditsUsed)) .foregroundStyle(Self.barColor) } if let peak = Self.peakPoint(model: model) { let capStart = max(peak.creditsUsed - Self.capHeight(maxValue: model.maxCreditsUsed), 0) BarMark( - x: .value("Day", peak.date, unit: .day), - yStart: .value("Cap start", capStart), - yEnd: .value("Cap end", peak.creditsUsed)) + x: .value(L("Day"), peak.date, unit: .day), + yStart: .value(L("Cap start"), capStart), + yEnd: .value(L("Cap end"), peak.creditsUsed)) .foregroundStyle(Color(nsColor: .systemYellow)) } } @@ -62,8 +62,11 @@ struct CreditsHistoryChartMenuView: View { } .chartLegend(.hidden) .frame(height: 130) - .accessibilityLabel("Credits history chart") - .accessibilityValue(model.points.isEmpty ? "No data" : "\(model.points.count) days of credits data") + .accessibilityLabel(L("Credits history chart")) + .accessibilityValue( + model.points.isEmpty + ? L("No data") + : String(format: L("%d days of credits data"), model.points.count)) .chartOverlay { proxy in GeometryReader { geo in ZStack(alignment: .topLeading) { @@ -101,7 +104,9 @@ struct CreditsHistoryChartMenuView: View { } if let total = model.totalCreditsUsed { - Text("Total (30d): \(total.formatted(.number.precision(.fractionLength(0...2)))) credits") + Text(String( + format: L("Total (30d): %@ credits"), + total.formatted(.number.precision(.fractionLength(0...2))))) .font(.caption) .foregroundStyle(.secondary) } @@ -302,17 +307,17 @@ struct CreditsHistoryChartMenuView: View { let day = model.breakdownByDayKey[key], let date = Self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return (L("Hover a bar for details"), nil) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let total = day.totalCreditsUsed.formatted(.number.precision(.fractionLength(0...2))) if day.services.isEmpty { - return ("\(dayLabel): \(total) credits", nil) + return (String(format: L("%@: %@ credits"), dayLabel, total), nil) } if day.services.count <= 1, let first = day.services.first { let used = first.creditsUsed.formatted(.number.precision(.fractionLength(0...2))) - return ("\(dayLabel): \(used) credits", first.service) + return (String(format: L("%@: %@ credits"), dayLabel, used), first.service) } let services = day.services @@ -324,6 +329,6 @@ struct CreditsHistoryChartMenuView: View { .map { "\($0.service) \($0.creditsUsed.formatted(.number.precision(.fractionLength(0...2))))" } .joined(separator: " · ") - return ("\(dayLabel): \(total) credits", services) + return (String(format: L("%@: %@ credits"), dayLabel, total), services) } } diff --git a/Sources/CodexBar/CursorLoginRunner.swift b/Sources/CodexBar/CursorLoginRunner.swift index d7e66700..4e5dbe9f 100644 --- a/Sources/CodexBar/CursorLoginRunner.swift +++ b/Sources/CodexBar/CursorLoginRunner.swift @@ -66,7 +66,7 @@ final class CursorLoginRunner { await self.resetSessionCache() guard self.openURL(Self.authURL) else { - let message = "Could not open Cursor login in your browser." + let message = L("Could not open Cursor login in your browser.") onPhaseChange(.failed(message)) self.logger.error("Cursor login browser launch failed") return Result(outcome: .failed(message), email: nil) @@ -103,10 +103,13 @@ final class CursorLoginRunner { } private static func timeoutMessage(lastError: Error?) -> String { - let hint = "Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." + let hint = L("Sign in to cursor.com in your browser, then refresh Cursor in CodexBar.") guard let lastError else { - return "Timed out waiting for Cursor login. \(hint)" + return String(format: L("Timed out waiting for Cursor login. %@"), hint) } - return "Timed out waiting for Cursor login. \(hint) Last error: \(lastError.localizedDescription)" + return String( + format: L("Timed out waiting for Cursor login. %@ Last error: %@"), + hint, + lastError.localizedDescription) } } diff --git a/Sources/CodexBar/Date+RelativeDescription.swift b/Sources/CodexBar/Date+RelativeDescription.swift index cb3c4f59..43413799 100644 --- a/Sources/CodexBar/Date+RelativeDescription.swift +++ b/Sources/CodexBar/Date+RelativeDescription.swift @@ -2,12 +2,12 @@ import Foundation enum RelativeTimeFormatters { @MainActor - static let full: RelativeDateTimeFormatter = { + static func full(locale: Locale) -> RelativeDateTimeFormatter { let formatter = RelativeDateTimeFormatter() - formatter.locale = Locale(identifier: "en_US") + formatter.locale = locale formatter.unitsStyle = .full return formatter - }() + } } extension Date { @@ -15,8 +15,9 @@ extension Date { func relativeDescription(now: Date = .now) -> String { let seconds = abs(now.timeIntervalSince(self)) if seconds < 15 { - return "just now" + return L("just now") } - return RelativeTimeFormatters.full.localizedString(for: self, relativeTo: now) + let locale = codexBarLocalizedLocale() + return RelativeTimeFormatters.full(locale: locale).localizedString(for: self, relativeTo: now) } } diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index ff3dca4c..0437288b 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -47,15 +47,32 @@ extension UsageMenuCardView.Model { let billing = input.snapshot?.minimaxUsage?.billingSummary { return [ - "Today: \(UsageFormatter.tokenCountString(billing.todayTokens)) tokens", - "Last 30 days: \(UsageFormatter.tokenCountString(billing.last30DaysTokens)) tokens", + String(format: L("Today: %@ tokens"), UsageFormatter.tokenCountString(billing.todayTokens)), + String( + format: L("Last 30 days: %@ tokens"), + UsageFormatter.tokenCountString(billing.last30DaysTokens)), + ] + } + + if input.provider == .deepseek, + input.showOptionalCreditsAndExtraUsage, + let usage = input.snapshot?.deepseekUsage + { + let symbol = usage.currency == "CNY" ? "¥" : "$" + let todayCostStr = usage.todayCost.map { "\(symbol)\(String(format: "%.4f", max(0, $0)))" } ?? "—" + return [ + String( + format: L("Today: %@ · %@ tokens"), + todayCostStr, + UsageFormatter.tokenCountString(usage.todayTokens)), + String(format: L("This month: %@ tokens"), UsageFormatter.tokenCountString(usage.currentMonthTokens)), ] } if input.provider == .ollama, input.snapshot?.identity?.loginMethod == "API key" { - return ["API key verified. Ollama does not expose Cloud quota limits through the API."] + return [L("API key verified. Ollama does not expose Cloud quota limits through the API.")] } return nil @@ -66,26 +83,32 @@ extension UsageMenuCardView.Model { let seven = usage.last7Days let thirty = usage.last30Days let historyLabel = usage.historyWindowLabel - let todayNote = "Today: \(UsageFormatter.usdString(today.costUSD)) · " + - "\(UsageFormatter.tokenCountString(today.totalTokens)) tokens" + let todayNote = String( + format: L("Today: %@ · %@ tokens"), + UsageFormatter.usdString(today.costUSD), + UsageFormatter.tokenCountString(today.totalTokens)) let sevenDayNote = "7d: \(UsageFormatter.usdString(seven.costUSD)) · " + - "\(UsageFormatter.tokenCountString(seven.requests)) requests" - let thirtyDayNote = "\(historyLabel): \(UsageFormatter.tokenCountString(thirty.totalTokens)) tokens · " + - "\(UsageFormatter.tokenCountString(thirty.requests)) requests" + "\(UsageFormatter.tokenCountString(seven.requests)) \(L("requests"))" + let thirtyDayNote = + "\(historyLabel): \(UsageFormatter.tokenCountString(thirty.totalTokens)) \(L("tokens")) · " + + "\(UsageFormatter.tokenCountString(thirty.requests)) \(L("requests"))" var notes: [String] = [ todayNote, sevenDayNote, thirtyDayNote, ] if let topModel = usage.topModels.first { - notes.append("Top model: \(topModel.name)") + notes.append("\(L("Top model")): \(topModel.name)") } return notes } static func inlineUsageDashboard(input: Input) -> InlineUsageDashboardModel? { - if let usage = input.snapshot?.openAIAPIUsage { - return self.openAIAPIInlineDashboard(usage) + if self.usesProviderCostHistoryAsPrimaryDashboard(input.provider), + let tokenSnapshot = primaryCostHistorySnapshot(input: input), + !tokenSnapshot.daily.isEmpty + { + return self.costHistoryInlineDashboard(provider: input.provider, snapshot: tokenSnapshot) } if input.provider == .claude, let usage = input.snapshot?.claudeAdminAPIUsage @@ -97,12 +120,6 @@ extension UsageMenuCardView.Model { { return Self.openRouterInlineDashboard(usage) } - if input.provider == .mistral, - let usage = input.snapshot?.mistralUsage, - !usage.daily.isEmpty - { - return Self.mistralInlineDashboard(usage) - } if input.provider == .zai, let modelUsage = input.snapshot?.zaiUsage?.modelUsage { @@ -115,6 +132,13 @@ extension UsageMenuCardView.Model { { return Self.minimaxInlineDashboard(billing) } + if input.provider == .deepseek, + input.showOptionalCreditsAndExtraUsage, + let usage = input.snapshot?.deepseekUsage, + !usage.daily.isEmpty + { + return Self.deepseekInlineDashboard(usage) + } if [.codex, .claude, .vertexai, .bedrock].contains(input.provider), input.tokenCostUsageEnabled, let tokenSnapshot = input.tokenSnapshot, @@ -125,39 +149,25 @@ extension UsageMenuCardView.Model { return nil } - fileprivate static func openAIAPIInlineDashboard(_ usage: OpenAIAPIUsageSnapshot) -> InlineUsageDashboardModel { - let today = usage.latestDay - let last7 = usage.last7Days - let last30 = usage.last30Days - let historyLabel = usage.historyWindowLabel - let points = usage.daily.suffix(usage.historyDays).map { - InlineUsageDashboardModel.Point( - id: $0.day, - label: Self.shortDayLabel($0.day), - value: $0.costUSD, - accessibilityValue: "\($0.day): \(UsageFormatter.usdString($0.costUSD))") - } - var details = [ - "\(historyLabel): \(UsageFormatter.tokenCountString(last30.totalTokens)) tokens · " + - "\(UsageFormatter.tokenCountString(last30.requests)) requests", - ] - if let topModel = usage.topModels.first { - details.append("Top model: \(Self.shortModelName(topModel.name))") + static func usesProviderCostHistoryAsPrimaryDashboard(_ provider: UsageProvider) -> Bool { + provider == .openai || provider == .mistral + } + + static func primaryCostHistorySnapshot(input: Input) -> CostUsageTokenSnapshot? { + switch input.provider { + case .openai: + if let projected = input.snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() { + return projected + } + return input.snapshot == nil ? input.tokenSnapshot : nil + case .mistral: + if let projected = input.snapshot?.mistralUsage?.toCostUsageTokenSnapshot() { + return projected + } + return input.snapshot == nil ? input.tokenSnapshot : nil + default: + return input.tokenSnapshot } - return InlineUsageDashboardModel( - accessibilityLabel: "OpenAI API \(usage.historyDays) day spend trend", - valueStyle: .currencyUSD, - kpis: [ - .init(title: "Today", value: UsageFormatter.usdString(today.costUSD), emphasis: true), - .init(title: "7d spend", value: UsageFormatter.usdString(last7.costUSD), emphasis: false), - .init( - title: "\(historyLabel) spend", - value: UsageFormatter.usdString(last30.costUSD), - emphasis: false), - .init(title: "Today req", value: UsageFormatter.tokenCountString(today.requests), emphasis: false), - ], - points: points, - detailLines: details) } private static func costHistoryInlineDashboard( @@ -165,52 +175,91 @@ extension UsageMenuCardView.Model { snapshot: CostUsageTokenSnapshot) -> InlineUsageDashboardModel { let historyDays = max(1, min(365, snapshot.historyDays)) - let historyLabel = historyDays == 1 ? "Today" : "\(historyDays)d" - let periodLabel = historyDays == 1 ? "today" : "\(historyDays) day" + let historyTitle = snapshot.historyLabel + ?? (historyDays == 1 + ? L("Today") + : historyDays == 30 + ? L("30d cost") + : "\(String(format: L("Last %d days"), historyDays)) \(L("Cost"))") + let tokenHistoryTitle = snapshot.historyLabel.map { "\($0) \(L("tokens"))" } + ?? (historyDays == 1 + ? L("Today tokens") + : historyDays == 30 + ? L("30d tokens") + : String(format: L("%@ tokens"), String(format: L("Last %d days"), historyDays))) + let requestHistoryTitle = snapshot.historyLabel.map { "\($0) \(L("requests"))" } + ?? (historyDays == 1 + ? L("Today requests") + : historyDays == 30 + ? L("30d requests") + : String(format: L("%@ requests"), String(format: L("Last %d days"), historyDays))) + let periodLabel = snapshot.historyLabel?.lowercased() + ?? (historyDays == 1 ? "today" : "\(historyDays) day") let points = snapshot.daily.suffix(historyDays).compactMap { entry -> InlineUsageDashboardModel.Point? in guard let cost = entry.costUSD else { return nil } return InlineUsageDashboardModel.Point( id: entry.date, label: Self.shortDayLabel(entry.date), value: cost, - accessibilityValue: "\(entry.date): \(UsageFormatter.usdString(cost))") + accessibilityValue: "\(entry.date): \(Self.costString(cost, currencyCode: snapshot.currencyCode))") } let latest = snapshot.daily.max { lhs, rhs in lhs.date < rhs.date } var details: [String] = [] if let topModel = Self.topCostModel(from: snapshot.daily) { - details.append("Top model: \(Self.shortModelName(topModel))") + details.append("\(L("Top model")): \(Self.shortModelName(topModel))") + } + if let requestCount = snapshot.last30DaysRequests { + details.append("\(requestHistoryTitle): \(UsageFormatter.tokenCountString(requestCount)) \(L("requests"))") } - if provider == .bedrock { - details.append("AWS Cost Explorer billing can lag.") + if let hint = Self.tokenUsageHint(provider: provider) { + details.append(hint) } else { - details.append(UsageFormatter.costEstimateHint(provider: provider)) + details.append(L("cost_estimate_hint")) } let providerName = ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue return InlineUsageDashboardModel( accessibilityLabel: "\(providerName) \(periodLabel) cost trend", - valueStyle: .currencyUSD, + valueStyle: Self.costValueStyle(currencyCode: snapshot.currencyCode), kpis: [ .init( - title: provider == .bedrock ? "Latest" : "Today", - value: latest?.costUSD.map(UsageFormatter.usdString) ?? "—", + title: provider == .bedrock || provider == .mistral ? L("Latest") : L("Today"), + value: latest?.costUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", emphasis: true), .init( - title: "\(historyLabel) cost", - value: snapshot.last30DaysCostUSD.map(UsageFormatter.usdString) ?? "—", + title: historyTitle, + value: snapshot.last30DaysCostUSD + .map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", emphasis: false), .init( - title: "\(historyLabel) tokens", + title: tokenHistoryTitle, value: snapshot.last30DaysTokens.map(UsageFormatter.tokenCountString) ?? "—", emphasis: false), - .init( - title: "Latest tokens", - value: latest?.totalTokens.map(UsageFormatter.tokenCountString) ?? "—", - emphasis: false), - ], + ] + Self.costHistoryTrailingKPIs(snapshot: snapshot, latest: latest), points: points, detailLines: details) } + private static func costHistoryTrailingKPIs( + snapshot: CostUsageTokenSnapshot, + latest: CostUsageDailyReport.Entry?) + -> [InlineUsageDashboardModel.KPI] + { + if let requests = snapshot.last30DaysRequests { + return [ + .init( + title: L("Requests"), + value: UsageFormatter.tokenCountString(requests), + emphasis: false), + ] + } + return [ + .init( + title: L("Latest tokens"), + value: latest?.totalTokens.map(UsageFormatter.tokenCountString) ?? "—", + emphasis: false), + ] + } + fileprivate static func claudeAdminAPIInlineDashboard(_ usage: ClaudeAdminAPIUsageSnapshot) -> InlineUsageDashboardModel { @@ -225,24 +274,24 @@ extension UsageMenuCardView.Model { accessibilityValue: "\($0.day): \(UsageFormatter.usdString($0.costUSD))") } var details = [ - "30d: \(UsageFormatter.tokenCountString(last30.totalTokens)) tokens", - "Cache read: \(UsageFormatter.tokenCountString(last30.cacheReadInputTokens)) tokens", + "30d: \(UsageFormatter.tokenCountString(last30.totalTokens)) \(L("tokens"))", + "\(L("Cache read")): \(UsageFormatter.tokenCountString(last30.cacheReadInputTokens)) \(L("tokens"))", ] if let topModel = usage.topModels.first { - details.append("Top model: \(Self.shortModelName(topModel.name))") + details.append("\(L("Top model")): \(Self.shortModelName(topModel.name))") } return InlineUsageDashboardModel( - accessibilityLabel: "Claude Admin API 30 day spend trend", + accessibilityLabel: L("Claude Admin API 30 day spend trend"), valueStyle: .currencyUSD, kpis: [ - .init(title: "Today", value: UsageFormatter.usdString(today.costUSD), emphasis: true), - .init(title: "7d spend", value: UsageFormatter.usdString(last7.costUSD), emphasis: false), + .init(title: L("Today"), value: UsageFormatter.usdString(today.costUSD), emphasis: true), + .init(title: L("7d spend"), value: UsageFormatter.usdString(last7.costUSD), emphasis: false), .init( - title: "30d spend", + title: L("30d spend"), value: UsageFormatter.usdString(last30.costUSD), emphasis: false), .init( - title: "Today tokens", + title: L("Today tokens"), value: UsageFormatter.tokenCountString(today.totalTokens), emphasis: false), ], @@ -252,9 +301,9 @@ extension UsageMenuCardView.Model { private static func openRouterInlineDashboard(_ usage: OpenRouterUsageSnapshot) -> InlineUsageDashboardModel? { let periodValues: [(String, String, Double?)] = [ - ("day", "Today", usage.keyUsageDaily), - ("week", "Week", usage.keyUsageWeekly), - ("month", "Month", usage.keyUsageMonthly), + ("day", L("Today"), usage.keyUsageDaily), + ("week", L("Week"), usage.keyUsageWeekly), + ("month", L("Month"), usage.keyUsageMonthly), ] let points = periodValues.compactMap { id, label, value -> InlineUsageDashboardModel.Point? in guard let value else { return nil } @@ -267,33 +316,33 @@ extension UsageMenuCardView.Model { guard !points.isEmpty else { return nil } var details: [String] = [] if let rate = usage.rateLimit { - details.append("Rate limit: \(rate.requests) / \(rate.interval)") + details.append(String(format: L("Rate limit: %d / %@"), rate.requests, rate.interval)) } switch usage.keyQuotaStatus { case .available: if let remaining = usage.keyRemaining { - details.append("Key remaining: \(Self.openRouterCurrencyString(remaining))") + details.append("\(L("Key remaining")): \(Self.openRouterCurrencyString(remaining))") } case .noLimitConfigured: - details.append("No limit set for the API key") + details.append(L("No limit set for the API key")) case .unavailable: - details.append("API key limit unavailable right now") + details.append(L("API key limit unavailable right now")) } return InlineUsageDashboardModel( - accessibilityLabel: "OpenRouter API key spend trend", + accessibilityLabel: L("OpenRouter API key spend trend"), valueStyle: .currencyUSD, kpis: [ - .init(title: "Balance", value: Self.openRouterCurrencyString(usage.balance), emphasis: true), + .init(title: L("Balance"), value: Self.openRouterCurrencyString(usage.balance), emphasis: true), .init( - title: "Today", + title: L("Today"), value: usage.keyUsageDaily.map(Self.openRouterCurrencyString) ?? "—", emphasis: false), .init( - title: "Week", + title: L("Week"), value: usage.keyUsageWeekly.map(Self.openRouterCurrencyString) ?? "—", emphasis: false), .init( - title: "Month", + title: L("Month"), value: usage.keyUsageMonthly.map(Self.openRouterCurrencyString) ?? "—", emphasis: false), ], @@ -301,42 +350,6 @@ extension UsageMenuCardView.Model { detailLines: details) } - private static func mistralInlineDashboard(_ usage: MistralUsageSnapshot) -> InlineUsageDashboardModel { - let points = usage.daily.suffix(30).map { - InlineUsageDashboardModel.Point( - id: $0.day, - label: Self.shortDayLabel($0.day), - value: $0.cost, - accessibilityValue: "\($0.day): \(Self.mistralCurrencyString($0.cost, symbol: usage.currencySymbol))") - } - let latest = usage.daily.last - let totalTokens = usage.totalInputTokens + usage.totalCachedTokens + usage.totalOutputTokens - var details = ["This month: \(UsageFormatter.tokenCountString(totalTokens)) tokens"] - if let topModel = Self.topMistralModel(from: usage.daily) { - details.append("Top model: \(Self.shortModelName(topModel))") - } - return InlineUsageDashboardModel( - accessibilityLabel: "Mistral API spend trend", - valueStyle: .currency(symbol: usage.currencySymbol), - kpis: [ - .init( - title: "Latest", - value: latest.map { Self.mistralCurrencyString($0.cost, symbol: usage.currencySymbol) } ?? "—", - emphasis: true), - .init( - title: "Month", - value: Self.mistralCurrencyString(usage.totalCost, symbol: usage.currencySymbol), - emphasis: false), - .init(title: "Models", value: "\(usage.modelCount)", emphasis: false), - .init( - title: "Latest tokens", - value: latest.map { UsageFormatter.tokenCountString($0.totalTokens) } ?? "—", - emphasis: false), - ], - points: points, - detailLines: details) - } - private static func zaiInlineDashboard(modelUsage: ZaiModelUsageData, now: Date) -> InlineUsageDashboardModel? { let bars = ZaiHourlyBars.from(modelData: modelUsage, range: .last24h, now: now) guard !bars.isEmpty else { return nil } @@ -348,26 +361,26 @@ extension UsageMenuCardView.Model { id: "\(index)-\(bar.label)", label: bar.label, value: Double(bar.totalTokens), - accessibilityValue: "\(bar.label): \(UsageFormatter.tokenCountString(bar.totalTokens)) tokens") + accessibilityValue: "\(bar.label): \(UsageFormatter.tokenCountString(bar.totalTokens)) \(L("tokens"))") } let topModel = Self.topZaiModel(from: bars) return InlineUsageDashboardModel( - accessibilityLabel: "z.ai hourly token trend", + accessibilityLabel: L("z.ai hourly token trend"), valueStyle: .tokens, kpis: [ - .init(title: "24h tokens", value: UsageFormatter.tokenCountString(total), emphasis: true), + .init(title: L("24h tokens"), value: UsageFormatter.tokenCountString(total), emphasis: true), .init( - title: "Latest hour", + title: L("Latest hour"), value: latest.map { UsageFormatter.tokenCountString($0.totalTokens) } ?? "—", emphasis: false), .init( - title: "Peak hour", + title: L("Peak hour"), value: peak.map { UsageFormatter.tokenCountString($0.totalTokens) } ?? "—", emphasis: false), - .init(title: "Models", value: "\(modelUsage.modelNames.count)", emphasis: false), + .init(title: L("Models"), value: "\(modelUsage.modelNames.count)", emphasis: false), ], points: points, - detailLines: topModel.map { ["Top model: \(Self.shortModelName($0))"] } ?? []) + detailLines: topModel.map { ["\(L("Top model")): \(Self.shortModelName($0))"] } ?? []) } private static func minimaxInlineDashboard(_ billing: MiniMaxBillingSummary) -> InlineUsageDashboardModel { @@ -376,36 +389,36 @@ extension UsageMenuCardView.Model { id: $0.day, label: Self.shortDayLabel($0.day), value: Double($0.tokens), - accessibilityValue: "\($0.day): \(UsageFormatter.tokenCountString($0.tokens)) tokens") + accessibilityValue: "\($0.day): \(UsageFormatter.tokenCountString($0.tokens)) \(L("tokens"))") } - var details = ["30d billing history from MiniMax web session"] + var details = [L("30d billing history from MiniMax web session")] if let topModel = billing.topModels.first { - details.append("Top model: \(Self.shortModelName(topModel.name))") + details.append("\(L("Top model")): \(Self.shortModelName(topModel.name))") } if let topMethod = billing.topMethods.first { - details.append("Top method: \(Self.shortModelName(topMethod.name))") + details.append("\(L("Top method")): \(Self.shortModelName(topMethod.name))") } if let cash = billing.last30DaysCash { - details.append("30d cash: \(Self.minimaxCashString(cash))") + details.append("\(L("30d cash")): \(Self.minimaxCashString(cash))") } return InlineUsageDashboardModel( - accessibilityLabel: "MiniMax 30 day token usage trend", + accessibilityLabel: L("MiniMax 30 day token usage trend"), valueStyle: .tokens, kpis: [ .init( - title: "Today", + title: L("Today"), value: UsageFormatter.tokenCountString(billing.todayTokens), emphasis: true), .init( - title: "30d tokens", + title: L("30d tokens"), value: UsageFormatter.tokenCountString(billing.last30DaysTokens), emphasis: false), .init( - title: "Today cash", + title: L("Today cash"), value: billing.todayCash.map(Self.minimaxCashString) ?? "—", emphasis: false), .init( - title: "Models", + title: L("Models"), value: "\(billing.topModels.count)", emphasis: false), ], @@ -413,6 +426,59 @@ extension UsageMenuCardView.Model { detailLines: details) } + private static func deepseekInlineDashboard(_ usage: DeepSeekUsageSummary) -> InlineUsageDashboardModel { + let symbol = usage.currency == "CNY" ? "¥" : "$" + let points = usage.daily.suffix(30).map { + InlineUsageDashboardModel.Point( + id: $0.date, + label: Self.shortDayLabel($0.date), + value: Double($0.totalTokens), + accessibilityValue: "\($0.date): \(UsageFormatter.tokenCountString($0.totalTokens)) \(L("tokens"))") + } + var details: [String] = [] + if let topModel = usage.topModel { + details.append("\(L("Top model")): \(Self.shortModelName(topModel))") + } + if let cacheHit = usage.categoryBreakdown.first(where: { $0.category == .promptCacheHitToken }) { + details.append("\(L("cache-hit input")): \(UsageFormatter.tokenCountString(cacheHit.tokens))") + } + if let cacheMiss = usage.categoryBreakdown.first(where: { $0.category == .promptCacheMissToken }) { + details.append("\(L("cache-miss input")): \(UsageFormatter.tokenCountString(cacheMiss.tokens))") + } + if let output = usage.categoryBreakdown.first(where: { $0.category == .responseToken }) { + details.append("\(L("output")): \(UsageFormatter.tokenCountString(output.tokens))") + } + details.append("\(L("requests")): \(usage.currentMonthRequestCount)") + + let todayCostStr = usage.todayCost.map { "\(symbol)\(String(format: "%.4f", max(0, $0)))" } ?? "—" + let monthCostStr = usage.currentMonthCost.map { "\(symbol)\(String(format: "%.4f", max(0, $0)))" } ?? "—" + let monthTokensStr = UsageFormatter.tokenCountString(usage.currentMonthTokens) + + return InlineUsageDashboardModel( + accessibilityLabel: L("DeepSeek 30 day token usage trend"), + valueStyle: .tokens, + kpis: [ + .init( + title: L("Today"), + value: "\(todayCostStr) · \(UsageFormatter.tokenCountString(usage.todayTokens))", + emphasis: true), + .init( + title: L("This month"), + value: "\(monthCostStr) · \(monthTokensStr)", + emphasis: false), + .init( + title: L("Models"), + value: usage.topModel.map { Self.shortModelName($0) } ?? "—", + emphasis: false), + .init( + title: L("Requests"), + value: "\(usage.currentMonthRequestCount)", + emphasis: false), + ], + points: points, + detailLines: details) + } + private static func topMistralModel(from entries: [MistralDailyUsageBucket]) -> String? { var tokens: [String: Int] = [:] for entry in entries { @@ -439,10 +505,6 @@ extension UsageMenuCardView.Model { }?.key } - private static func mistralCurrencyString(_ value: Double, symbol: String) -> String { - "\(symbol)\(String(format: "%.4f", max(0, value)))" - } - private static func openRouterCurrencyString(_ value: Double) -> String { String(format: "$%.2f", value) } @@ -451,6 +513,20 @@ extension UsageMenuCardView.Model { String(format: "%.2f", max(0, value)) } + private static func costString(_ value: Double, currencyCode: String) -> String { + UsageFormatter.currencyString(value, currencyCode: currencyCode) + } + + private static func costValueStyle(currencyCode: String) -> InlineUsageDashboardModel.ValueStyle { + if currencyCode == "USD" { return .currencyUSD } + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = currencyCode + formatter.locale = Locale(identifier: "en_US") + let symbol = formatter.currencySymbol ?? currencyCode + return .currency(symbol: symbol) + } + private static func shortDayLabel(_ day: String) -> String { let pieces = day.split(separator: "-") guard pieces.count == 3, let rawDay = Int(pieces[2]) else { return day } @@ -484,10 +560,6 @@ struct InlineUsageDashboardContent: View { private let model: InlineUsageDashboardModel @Environment(\.menuItemHighlighted) private var isHighlighted - init(snapshot: OpenAIAPIUsageSnapshot) { - self.model = UsageMenuCardView.Model.openAIAPIInlineDashboard(snapshot) - } - init(model: InlineUsageDashboardModel) { self.model = model } diff --git a/Sources/CodexBar/KeychainPromptCoordinator.swift b/Sources/CodexBar/KeychainPromptCoordinator.swift index abbeb0ca..7a458651 100644 --- a/Sources/CodexBar/KeychainPromptCoordinator.swift +++ b/Sources/CodexBar/KeychainPromptCoordinator.swift @@ -2,6 +2,58 @@ import AppKit import CodexBarCore import SweetCookieKit +private enum KeychainPromptMessage { + static let browserCookie = + "CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies " + + "and authenticate your account. Click OK to continue." + + static let claudeOAuth = + "CodexBar will ask macOS Keychain for the Claude Code OAuth token " + + "so it can fetch your Claude usage. Click OK to continue." + static let codexCookie = + "CodexBar will ask macOS Keychain for your OpenAI cookie header " + + "so it can fetch Codex dashboard extras. Click OK to continue." + static let claudeCookie = + "CodexBar will ask macOS Keychain for your Claude cookie header " + + "so it can fetch Claude web usage. Click OK to continue." + static let cursorCookie = + "CodexBar will ask macOS Keychain for your Cursor cookie header " + + "so it can fetch usage. Click OK to continue." + static let openCodeCookie = + "CodexBar will ask macOS Keychain for your OpenCode cookie header " + + "so it can fetch usage. Click OK to continue." + static let factoryCookie = + "CodexBar will ask macOS Keychain for your Factory cookie header " + + "so it can fetch usage. Click OK to continue." + static let zaiToken = + "CodexBar will ask macOS Keychain for your z.ai API token " + + "so it can fetch usage. Click OK to continue." + static let syntheticToken = + "CodexBar will ask macOS Keychain for your Synthetic API key " + + "so it can fetch usage. Click OK to continue." + static let copilotToken = + "CodexBar will ask macOS Keychain for your GitHub Copilot token " + + "so it can fetch usage. Click OK to continue." + static let kimiToken = + "CodexBar will ask macOS Keychain for your Kimi auth token " + + "so it can fetch usage. Click OK to continue." + static let kimiK2Token = + "CodexBar will ask macOS Keychain for your Kimi K2 API key " + + "so it can fetch usage. Click OK to continue." + static let minimaxCookie = + "CodexBar will ask macOS Keychain for your MiniMax cookie header " + + "so it can fetch usage. Click OK to continue." + static let minimaxToken = + "CodexBar will ask macOS Keychain for your MiniMax API token " + + "so it can fetch usage. Click OK to continue." + static let augmentCookie = + "CodexBar will ask macOS Keychain for your Augment cookie header " + + "so it can fetch usage. Click OK to continue." + static let ampCookie = + "CodexBar will ask macOS Keychain for your Amp cookie header " + + "so it can fetch usage. Click OK to continue." +} + enum KeychainPromptCoordinator { private static let promptLock = NSLock() private static let log = CodexBarLog.logger(LogCategories.keychainPrompt) @@ -22,93 +74,47 @@ enum KeychainPromptCoordinator { } private static func presentBrowserCookiePrompt(_ context: BrowserCookieKeychainPromptContext) { - let title = "Keychain Access Required" - let message = [ - "CodexBar will ask macOS Keychain for “\(context.label)” so it can decrypt browser cookies", - "and authenticate your account. Click OK to continue.", - ].joined(separator: " ") + let title = L("Keychain Access Required") + let message = L( + KeychainPromptMessage.browserCookie, + context.label) self.log.info("Browser cookie keychain prompt requested", metadata: ["label": context.label]) self.presentAlert(title: title, message: message) } private static func keychainCopy(for context: KeychainPromptContext) -> (title: String, message: String) { - let title = "Keychain Access Required" + let title = L("Keychain Access Required") switch context.kind { case .claudeOAuth: - return (title, [ - "CodexBar will ask macOS Keychain for the Claude Code OAuth token", - "so it can fetch your Claude usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.claudeOAuth)) case .codexCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your OpenAI cookie header", - "so it can fetch Codex dashboard extras. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.codexCookie)) case .claudeCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your Claude cookie header", - "so it can fetch Claude web usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.claudeCookie)) case .cursorCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your Cursor cookie header", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.cursorCookie)) case .opencodeCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your OpenCode cookie header", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.openCodeCookie)) case .factoryCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your Factory cookie header", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.factoryCookie)) case .zaiToken: - return (title, [ - "CodexBar will ask macOS Keychain for your z.ai API token", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.zaiToken)) case .syntheticToken: - return (title, [ - "CodexBar will ask macOS Keychain for your Synthetic API key", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.syntheticToken)) case .copilotToken: - return (title, [ - "CodexBar will ask macOS Keychain for your GitHub Copilot token", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.copilotToken)) case .kimiToken: - return (title, [ - "CodexBar will ask macOS Keychain for your Kimi auth token", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.kimiToken)) case .kimiK2Token: - return (title, [ - "CodexBar will ask macOS Keychain for your Kimi K2 API key", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.kimiK2Token)) case .minimaxCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your MiniMax cookie header", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.minimaxCookie)) case .minimaxToken: - return (title, [ - "CodexBar will ask macOS Keychain for your MiniMax API token", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.minimaxToken)) case .augmentCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your Augment cookie header", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.augmentCookie)) case .ampCookie: - return (title, [ - "CodexBar will ask macOS Keychain for your Amp cookie header", - "so it can fetch usage. Click OK to continue.", - ].joined(separator: " ")) + return (title, L(KeychainPromptMessage.ampCookie)) } } @@ -132,8 +138,8 @@ enum KeychainPromptCoordinator { @MainActor private static func showAlert(title: String, message: String) { let alert = NSAlert() - alert.messageText = title - alert.informativeText = message + alert.messageText = L(title) + alert.informativeText = L(message) alert.addButton(withTitle: L("OK")) _ = alert.runModal() } diff --git a/Sources/CodexBar/Localization.swift b/Sources/CodexBar/Localization.swift index d9a3bbfc..8fbdf321 100644 --- a/Sources/CodexBar/Localization.swift +++ b/Sources/CodexBar/Localization.swift @@ -1,5 +1,10 @@ +import CodexBarCore import Foundation +enum CodexBarLocalizationOverride { + @TaskLocal static var appLanguage: String? +} + private func appLanguageDefaults() -> UserDefaults { if Bundle.main.bundleIdentifier != nil { return .standard @@ -11,6 +16,31 @@ private func appLanguageDefaults() -> UserDefaults { return UserDefaults(suiteName: "CodexBar") ?? .standard } +private func isRunningTestsProcess() -> Bool { + let env = ProcessInfo.processInfo.environment + if env["XCTestConfigurationFilePath"] != nil { return true } + if env["TESTING_LIBRARY_VERSION"] != nil { return true } + if env["SWIFT_TESTING"] != nil { return true } + return NSClassFromString("XCTestCase") != nil +} + +private let standardAppLanguageAtProcessStart = UserDefaults.standard.string(forKey: "appLanguage") + +private func resolvedAppLanguage() -> String { + if let override = CodexBarLocalizationOverride.appLanguage { + return override + } + if isRunningTestsProcess() { + let current = UserDefaults.standard.string(forKey: "appLanguage") + return current == standardAppLanguageAtProcessStart ? "en" : current ?? "" + } + return appLanguageDefaults().string(forKey: "appLanguage") ?? "" +} + +func codexBarLocalizationSignature() -> String { + resolvedAppLanguage() +} + func codexBarLocalizationResourceBundle( mainBundle: Bundle = .main, bundleName: String = "CodexBar_CodexBar") -> Bundle @@ -36,7 +66,7 @@ func codexBarLocalizationResourceBundle( private func localizedBundle() -> Bundle { let resourceBundle = codexBarLocalizationResourceBundle() - let language = appLanguageDefaults().string(forKey: "appLanguage") ?? "" + let language = resolvedAppLanguage() if !language.isEmpty { if let bundle = lprojBundle(named: language, in: resourceBundle) { return bundle @@ -79,6 +109,21 @@ func L(_ key: String, _ arguments: CVarArg...) -> String { String(format: L(key), arguments: arguments) } +func codexBarLocalizedLocale() -> Locale { + let language = resolvedAppLanguage() + guard !language.isEmpty else { return .current } + switch language.lowercased() { + case "zh-hans": + return Locale(identifier: "zh-Hans") + case "zh-hant": + return Locale(identifier: "zh-Hant") + case "pt-br": + return Locale(identifier: "pt-BR") + default: + return Locale(identifier: language) + } +} + func codexBarLocalizedString(_ key: String, bundle: Bundle, resourceBundle: Bundle) -> String { let value = bundle.localizedString(forKey: key, value: nil, table: nil) let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -95,3 +140,13 @@ func codexBarLocalizedString(_ key: String, bundle: Bundle, resourceBundle: Bund let fallback = englishBundle.localizedString(forKey: key, value: nil, table: nil) return fallback.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? key : fallback } + +func configureUsageFormatterLocalizationProvider() { + UsageFormatter.setLocalizationProvider { key in + let resourceBundle = codexBarLocalizationResourceBundle() + return codexBarLocalizedString(key, bundle: localizedBundle(), resourceBundle: resourceBundle) + } + UsageFormatter.setLocaleProvider { + codexBarLocalizedLocale() + } +} diff --git a/Sources/CodexBar/ManagedCodexAccountService.swift b/Sources/CodexBar/ManagedCodexAccountService.swift index 7b6d6ec2..b4ba6cee 100644 --- a/Sources/CodexBar/ManagedCodexAccountService.swift +++ b/Sources/CodexBar/ManagedCodexAccountService.swift @@ -35,12 +35,27 @@ protocol ManagedCodexWorkspaceSelecting: Sendable { } enum ManagedCodexAccountServiceError: Error, Equatable { - case loginFailed + case loginFailed(CodexLoginRunner.Result) case missingEmail case workspaceSelectionCancelled case unsafeManagedHome(String) } +extension ManagedCodexAccountServiceError { + var userFacingMessage: String { + switch self { + case let .loginFailed(result): + CodexLoginAlertPresentation.managedLoginFailureMessage(for: result) + case .missingEmail: + L("managed_login_missing_email") + case .workspaceSelectionCancelled: + L("workspace_selection_cancelled") + case let .unsafeManagedHome(path): + String(format: L("unsafe_managed_home"), path) + } + } +} + struct ManagedCodexHomeFactory: ManagedCodexHomeProducing { let root: URL @@ -232,7 +247,7 @@ final class ManagedCodexAccountService { do { let result = await self.loginRunner.run(homePath: homeURL.path, timeout: timeout) - guard case .success = result.outcome else { throw ManagedCodexAccountServiceError.loginFailed } + guard case .success = result.outcome else { throw ManagedCodexAccountServiceError.loginFailed(result) } let identity = try self.identityReader.loadAccountIdentity(homePath: homeURL.path) guard let rawEmail = identity.email?.trimmingCharacters(in: .whitespacesAndNewlines), diff --git a/Sources/CodexBar/MemoryPressureRelief.swift b/Sources/CodexBar/MemoryPressureRelief.swift new file mode 100644 index 00000000..7a2162e3 --- /dev/null +++ b/Sources/CodexBar/MemoryPressureRelief.swift @@ -0,0 +1,7 @@ +import Darwin + +enum MemoryPressureRelief { + static func releaseFreeMallocPages() { + _ = malloc_zone_pressure_relief(nil, 0) + } +} diff --git a/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift b/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift new file mode 100644 index 00000000..6e7c6caf --- /dev/null +++ b/Sources/CodexBar/MenuBarStatusItemDefaultsRepair.swift @@ -0,0 +1,45 @@ +import Foundation + +enum MenuBarStatusItemDefaultsRepair { + static let didRepairKey = "hasRepairedHiddenStatusItemVisibilityDefaults" + private static let visibilityPrefix = "NSStatusItem VisibleCC " + private static let legacyAutosavePrefix = "codexbar-" + + static func repairHiddenVisibilityDefaultsIfNeeded(defaults: UserDefaults) -> [String] { + guard !defaults.bool(forKey: self.didRepairKey) else { return [] } + + let repairedKeys = defaults.dictionaryRepresentation().keys + .filter { key in + self.shouldRepair(key: key, value: defaults.object(forKey: key)) + } + .sorted() + + for key in repairedKeys { + defaults.removeObject(forKey: key) + } + defaults.set(true, forKey: self.didRepairKey) + return repairedKeys + } + + static func shouldRepair(key: String, value: Any?) -> Bool { + guard key.hasPrefix(self.visibilityPrefix), self.isFalse(value) else { return false } + let itemName = String(key.dropFirst(self.visibilityPrefix.count)) + return itemName.hasPrefix(self.legacyAutosavePrefix) || self.isDefaultStatusItemName(itemName) + } + + private static func isDefaultStatusItemName(_ itemName: String) -> Bool { + guard itemName.hasPrefix("Item-") else { return false } + return itemName.dropFirst("Item-".count).allSatisfy(\.isNumber) + } + + private static func isFalse(_ value: Any?) -> Bool { + switch value { + case let number as NSNumber: + !number.boolValue + case let bool as Bool: + !bool + default: + false + } + } +} diff --git a/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift b/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift new file mode 100644 index 00000000..dbbf7841 --- /dev/null +++ b/Sources/CodexBar/MenuBarStatusItemPlacementPreflight.swift @@ -0,0 +1,69 @@ +import Foundation + +enum MenuBarStatusItemPlacementPreflight { + static let preferredPositionPrefix = "NSStatusItem Preferred Position " + static let lowPreferredPosition: Double = 0 + static let suspiciousPreferredPositionThreshold: Double = 100 + + static func preferredPositionKey(autosaveName: String) -> String { + "\(self.preferredPositionPrefix)\(autosaveName)" + } + + @discardableResult + static func prepare(defaults: UserDefaults, autosaveName: String, legacyDefaultItemIndex: Int? = nil) -> Bool { + let key = self.preferredPositionKey(autosaveName: autosaveName) + let value = defaults.object(forKey: key) + guard value != nil || !self.shouldPreserveMissingStableKey( + defaults: defaults, + legacyDefaultItemIndex: legacyDefaultItemIndex) + else { + return false + } + guard self.shouldSetPreferredPosition(value) else { return false } + defaults.set(self.lowPreferredPosition, forKey: key) + return true + } + + static func shouldSetPreferredPosition(_ value: Any?) -> Bool { + guard let value else { return true } + guard let number = value as? NSNumber else { return true } + return number.doubleValue > self.suspiciousPreferredPositionThreshold + } + + static func shouldPreserveMissingStableKey(defaults: UserDefaults, legacyDefaultItemIndex: Int?) -> Bool { + guard let legacyDefaultItemIndex else { return false } + return self.legacyPreferredPositions(defaults: defaults).contains { position in + position.itemIndex == legacyDefaultItemIndex && !self.shouldSetPreferredPosition(position.value) + } + } + + static func isLegacyPreferredPositionKey(_ key: String) -> Bool { + guard key.hasPrefix(self.preferredPositionPrefix) else { return false } + return self.isDefaultStatusItemName(String(key.dropFirst(self.preferredPositionPrefix.count))) + } + + private static func legacyPreferredPositions(defaults: UserDefaults) -> [LegacyPreferredPosition] { + defaults.dictionaryRepresentation().compactMap { key, value -> LegacyPreferredPosition? in + guard key.hasPrefix(self.preferredPositionPrefix) else { return nil } + let itemName = String(key.dropFirst(self.preferredPositionPrefix.count)) + guard let itemIndex = self.defaultStatusItemIndex(itemName) else { return nil } + return LegacyPreferredPosition(itemIndex: itemIndex, value: value) + } + } + + private struct LegacyPreferredPosition { + var itemIndex: Int + var value: Any + } + + private static func isDefaultStatusItemName(_ itemName: String) -> Bool { + self.defaultStatusItemIndex(itemName) != nil + } + + private static func defaultStatusItemIndex(_ itemName: String) -> Int? { + guard itemName.hasPrefix("Item-") else { return nil } + let suffix = itemName.dropFirst("Item-".count) + guard suffix.allSatisfy(\.isNumber) else { return nil } + return Int(suffix) + } +} diff --git a/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift b/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift new file mode 100644 index 00000000..c479c859 --- /dev/null +++ b/Sources/CodexBar/MenuBarStatusItemWindowProbe.swift @@ -0,0 +1,100 @@ +import AppKit +import CoreGraphics +import Foundation + +struct MenuBarStatusItemWindowSnapshot: Equatable, CustomStringConvertible { + let name: String + let ownerName: String + let bounds: CGRect + let isOnscreen: Bool + let displayBounds: CGRect? + + var isWithinDisplayBounds: Bool { + guard let displayBounds else { return false } + return displayBounds.contains(self.bounds) + } + + var description: String { + let display = self.displayBounds.map { + "display=\(Int($0.minX)),\(Int($0.minY)) \(Int($0.width))x\(Int($0.height))" + } ?? "display=nil" + return "name=\(self.name),owner=\(self.ownerName),x=\(Int(self.bounds.minX))," + + "w=\(Int(self.bounds.width)),onscreen=\(self.isOnscreen)," + + "withinDisplay=\(self.isWithinDisplayBounds),\(display)" + } +} + +enum MenuBarStatusItemWindowProbe { + static func snapshots(matching names: Set) -> [MenuBarStatusItemWindowSnapshot] { + self.snapshots( + matching: names, + windowInfo: self.windowInfo(), + displayBounds: NSScreen.screens.map(\.frame)) + } + + static func snapshots( + matching names: Set, + windowInfo: [[String: Any]], + displayBounds: [CGRect]) + -> [MenuBarStatusItemWindowSnapshot] + { + guard !names.isEmpty else { return [] } + return windowInfo.compactMap { record in + self.snapshot(record: record, matching: names, displayBounds: displayBounds) + } + } + + private static func windowInfo() -> [[String: Any]] { + guard let windows = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] else { + return [] + } + return windows + } + + private static func snapshot( + record: [String: Any], + matching names: Set, + displayBounds: [CGRect]) + -> MenuBarStatusItemWindowSnapshot? + { + guard let name = record[kCGWindowName as String] as? String, + names.contains(name), + let bounds = self.bounds(record[kCGWindowBounds as String]) + else { return nil } + let ownerName = record[kCGWindowOwnerName as String] as? String ?? "unknown" + let isOnscreen = (record[kCGWindowIsOnscreen as String] as? NSNumber)?.boolValue + ?? record[kCGWindowIsOnscreen as String] as? Bool + ?? false + return MenuBarStatusItemWindowSnapshot( + name: name, + ownerName: ownerName, + bounds: bounds, + isOnscreen: isOnscreen, + displayBounds: displayBounds.first { $0.intersects(bounds) }) + } + + private static func bounds(_ value: Any?) -> CGRect? { + guard let dictionary = value as? [String: Any], + let x = self.double(dictionary["X"]), + let y = self.double(dictionary["Y"]), + let width = self.double(dictionary["Width"]), + let height = self.double(dictionary["Height"]) + else { return nil } + return CGRect(x: x, y: y, width: width, height: height) + } + + private static func double(_ value: Any?) -> Double? { + switch value { + case let number as NSNumber: + number.doubleValue + case let double as Double: + double + case let int as Int: + Double(int) + case let cgFloat as CGFloat: + Double(cgFloat) + default: + nil + } + } +} diff --git a/Sources/CodexBar/MenuBarVisibilityWatcher.swift b/Sources/CodexBar/MenuBarVisibilityWatcher.swift index 174e87ea..36d2f383 100644 --- a/Sources/CodexBar/MenuBarVisibilityWatcher.swift +++ b/Sources/CodexBar/MenuBarVisibilityWatcher.swift @@ -47,7 +47,6 @@ enum MenuBarVisibilityWatcher { static let startupCheckDelay: TimeInterval = 2 static let screenChangeCheckDelay: Duration = .milliseconds(750) static let screenChangeFollowUpDelay: Duration = .seconds(2) - static let screenChangeRecoveryRetryLimit = 3 static let settingsURL = URL(string: "x-apple.systempreferences:com.apple.MenuBarSettings")! @MainActor @@ -80,7 +79,16 @@ enum MenuBarVisibilityWatcher { static func isBlockedSnapshot(snapshot: StatusItemVisibilitySnapshot) -> Bool { guard snapshot.isVisible else { return false } guard snapshot.hasButton else { return true } - return !snapshot.hasWindow || !snapshot.hasScreen || !snapshot.isOnCurrentScreen || snapshot.buttonWidth <= 0 + // Menu bar managers can park status-item windows off the current screen while preserving the + // underlying NSStatusItem. Recreating in that state makes those managers see a new item. + return !snapshot.hasWindow || snapshot.buttonWidth <= 0 + } + + static func isDisplacedSnapshot(snapshot: StatusItemVisibilitySnapshot) -> Bool { + guard snapshot.isVisible, snapshot.hasButton, snapshot.hasWindow, snapshot.buttonWidth > 0 else { + return false + } + return !snapshot.hasScreen || !snapshot.isOnCurrentScreen } static func hasBlockedVisibleSnapshots(_ snapshots: [StatusItemVisibilitySnapshot]) -> Bool { @@ -97,6 +105,12 @@ enum MenuBarVisibilityWatcher { } } + static func hasAnyDisplacedVisibleSnapshot(_ snapshots: [StatusItemVisibilitySnapshot]) -> Bool { + snapshots.contains { snapshot in + self.isDisplacedSnapshot(snapshot: snapshot) + } + } + @MainActor static func visibilitySnapshots(_ items: [NSStatusItem]) -> [StatusItemVisibilitySnapshot] { items.map { item in @@ -119,27 +133,17 @@ enum MenuBarVisibilityWatcher { return self.hasAnyBlockedVisibleSnapshot(snapshots) } - static func shouldAttemptScreenChangeRecovery( - previousScreenCount: Int, - currentScreenCount: Int, + static func shouldRefreshScreenChangePlacement( + previousScreenCount _: Int, + currentScreenCount _: Int, snapshots: [StatusItemVisibilitySnapshot]) -> Bool { - if self.hasAnyBlockedVisibleSnapshot(snapshots) { - return true - } - guard currentScreenCount < previousScreenCount else { return false } - return snapshots.contains { snapshot in - snapshot.isVisible - } + self.hasAnyDisplacedVisibleSnapshot(snapshots) } - static func shouldRetryScreenChangeRecovery( - attempt: Int, - snapshots: [StatusItemVisibilitySnapshot]) - -> Bool - { - attempt < self.screenChangeRecoveryRetryLimit && self.hasAnyBlockedVisibleSnapshot(snapshots) + static func shouldAttemptScreenChangeRecovery(snapshots: [StatusItemVisibilitySnapshot]) -> Bool { + self.hasAnyBlockedVisibleSnapshot(snapshots) } static func shouldShowGuidance(defaults: UserDefaults, now: Date = Date()) -> Bool { @@ -199,7 +203,10 @@ extension StatusItemController { self.menuLogger.error( "Status item failed to materialize; recreating status items", - metadata: ["snapshots": snapshots.map(\.description).joined(separator: " | ")]) + metadata: [ + "snapshots": snapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), + ]) self.recreateStatusItemsForVisibilityRecovery() let recoveredSnapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) @@ -216,7 +223,10 @@ extension StatusItemController { self.menuLogger.error( "Status item still failed to materialize after recreation", - metadata: ["snapshots": recoveredSnapshots.map(\.description).joined(separator: " | ")]) + metadata: [ + "snapshots": recoveredSnapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), + ]) guard #available(macOS 26.0, *), MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults, now: now) else { @@ -260,7 +270,22 @@ extension StatusItemController { let settledCurrentScreenCount = NSScreen.screens.count self.lastKnownScreenCount = settledCurrentScreenCount let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) - guard MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( + if MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery(snapshots: snapshots) { + self.menuLogger.error( + "Display configuration changed; recreating status items", + metadata: [ + "previousScreenCount": "\(previousScreenCount)", + "currentScreenCount": "\(settledCurrentScreenCount)", + "capturedScreenCount": "\(currentScreenCount)", + "snapshots": snapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), + ]) + self.recreateStatusItemsForVisibilityRecovery() + self.schedulePostScreenChangeRecoveryVerification(attempt: 1) + return + } + + guard MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( previousScreenCount: previousScreenCount, currentScreenCount: settledCurrentScreenCount, snapshots: snapshots) @@ -268,16 +293,15 @@ extension StatusItemController { return } - self.menuLogger.error( - "Display configuration changed; recreating status items", + self.menuLogger.info( + "Display configuration changed; refreshing existing status items", metadata: [ "previousScreenCount": "\(previousScreenCount)", "currentScreenCount": "\(settledCurrentScreenCount)", "capturedScreenCount": "\(currentScreenCount)", "snapshots": snapshots.map(\.description).joined(separator: " | "), ]) - self.recreateStatusItemsForVisibilityRecovery() - self.schedulePostScreenChangeRecoveryVerification(attempt: 1) + self.refreshExistingStatusItemsForVisibilityRecovery() } private func schedulePostScreenChangeRecoveryVerification(attempt: Int) { @@ -300,32 +324,41 @@ extension StatusItemController { return } - guard MenuBarVisibilityWatcher.shouldRetryScreenChangeRecovery(attempt: attempt, snapshots: snapshots) else { - self.menuLogger.error( - "Status item still blocked after display-change recovery retries", - metadata: [ - "attempt": "\(attempt)", - "snapshots": snapshots.map(\.description).joined(separator: " | "), - ]) - guard #available(macOS 26.0, *), - MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults) - else { - return - } - MenuBarVisibilityWatcher.presentGuidance(defaults: self.settings.userDefaults) - return - } self.menuLogger.error( "Status item still blocked after display-change recovery; recreating status items again", metadata: [ "attempt": "\(attempt)", "snapshots": snapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), ]) self.recreateStatusItemsForVisibilityRecovery() - self.schedulePostScreenChangeRecoveryVerification(attempt: attempt + 1) + // No further async retries: a menu bar manager may park the newly recreated item in a state + // that still looks blocked, causing repeated NSStatusItem destruction that corrupts Control Center. + // Instead, do one synchronous re-check to surface guidance if macOS itself is blocking the item. + let finalSnapshots = MenuBarVisibilityWatcher.visibilitySnapshots(self.startupVisibilityStatusItems) + guard MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot(finalSnapshots) else { return } + self.menuLogger.error( + "Status item still blocked after display-change recovery recreation", + metadata: [ + "snapshots": finalSnapshots.map(\.description).joined(separator: " | "), + "windows": self.statusItemWindowDiagnosticsDescription(), + ]) + guard #available(macOS 26.0, *), + MenuBarVisibilityWatcher.shouldShowGuidance(defaults: self.settings.userDefaults) + else { return } + MenuBarVisibilityWatcher.presentGuidance(defaults: self.settings.userDefaults) } private var startupVisibilityStatusItems: [NSStatusItem] { [self.statusItem] + Array(self.statusItems.values) } + + private func statusItemWindowDiagnosticsDescription() -> String { + let names = Set(self.startupVisibilityStatusItems.compactMap { item in + item.autosaveName.isEmpty ? nil : item.autosaveName + }) + let snapshots = MenuBarStatusItemWindowProbe.snapshots(matching: names) + guard !snapshots.isEmpty else { return "none" } + return snapshots.map(\.description).joined(separator: " | ") + } } diff --git a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift index 89505ac5..abe15e28 100644 --- a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift +++ b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift @@ -18,4 +18,63 @@ extension UsageMenuCardView.Model { .map { showUsed ? 100 - Double($0) : Double($0) } .filter { $0 > 0 && $0 < 100 } } + + /// Merges quota warning markers with optional work-day boundary markers. + /// Preserves original warning-marker ordering when workdayMarkers is empty, + /// sorts the combined set when workday markers are present. + static func mergedMarkerPercents( + warningMarkers: [Double], + workdayMarkers: [Double]) -> [Double] + { + let combined = warningMarkers + workdayMarkers + return workdayMarkers.isEmpty ? combined : combined.sorted() + } + + /// Combines quota warning markers with optional work-day boundary markers + /// into a single sorted array. Workday markers are only applied when + /// includeWorkdayMarkers is true and windowMinutes == 10080. + static func markerPercents( + thresholds: [Int]?, + showUsed: Bool, + workDays: Int?, + windowMinutes: Int?, + includeWorkdayMarkers: Bool) -> [Double] + { + let warningMarkers = Self.warningMarkerPercents(thresholds: thresholds, showUsed: showUsed) + let workdayMarkers = includeWorkdayMarkers + ? workDayMarkerPercents(workDays: workDays, windowMinutes: windowMinutes) + : [] + return Self.mergedMarkerPercents(warningMarkers: warningMarkers, workdayMarkers: workdayMarkers) + } + + static func weeklyMarkerPercents(input: Input, windowMinutes: Int?) -> [Double] { + UsageMenuCardView.Model.markerPercents( + thresholds: input.quotaWarningThresholds[.weekly], + showUsed: input.usageBarsShowUsed, + workDays: input.workDaysPerWeek, + windowMinutes: windowMinutes, + includeWorkdayMarkers: true) + } + + static func codexLaneMarkerPercents( + input: Input, + lane: CodexConsumerProjection.RateLane, + windowMinutes: Int?) -> [Double] + { + UsageMenuCardView.Model.markerPercents( + thresholds: input.quotaWarningThresholds[lane.quotaWarningWindow], + showUsed: input.usageBarsShowUsed, + workDays: input.workDaysPerWeek, + windowMinutes: windowMinutes, + includeWorkdayMarkers: lane == .weekly) + } +} + +/// Returns boundary percentages for work day markers on a weekly progress bar. +/// Only valid when windowMinutes == 10080 (standard 7-day week). +/// nil workDays means feature is disabled. +func workDayMarkerPercents(workDays: Int?, windowMinutes: Int?) -> [Double] { + guard workDays != nil, windowMinutes == 10080 else { return [] } + guard let wd = workDays, wd >= 2, wd <= 7 else { return [] } + return (1.. CostUsageTokenSnapshot? { + if usesProviderCostHistoryAsPrimaryDashboard(input.provider), input.snapshot != nil { + return primaryCostHistorySnapshot(input: input) + } + return input.tokenSnapshot + } + static func creditsLine( metadata: ProviderMetadata, credits: CreditsSnapshot?, @@ -14,7 +21,7 @@ extension UsageMenuCardView.Model { if let error, !error.isEmpty { return error.trimmingCharacters(in: .whitespacesAndNewlines) } - return metadata.creditsHint + return L(metadata.creditsHint) } static func tokenUsageSection( @@ -23,36 +30,38 @@ extension UsageMenuCardView.Model { snapshot: CostUsageTokenSnapshot?, error: String?) -> TokenUsageSection? { - guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { + guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { return nil } guard enabled else { return nil } guard let snapshot else { return nil } - let sessionCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let sessionCost = snapshot.sessionCostUSD.map { + UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + } ?? "—" let sessionTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } + let sessionLabel = if provider == .bedrock || provider == .mistral { + Self.latestBillingDayLabel(from: snapshot) + } else { + L("Today") + } let sessionLine: String = { - if provider == .bedrock { - let label = Self.bedrockLatestBillingDayLabel(from: snapshot) - if let sessionTokens { - return "\(label): \(sessionCost) · \(sessionTokens) tokens" - } - return "\(label): \(sessionCost)" - } if let sessionTokens { - return "Today: \(sessionCost) · \(sessionTokens) tokens" + return String(format: L("%@: %@ · %@ tokens"), sessionLabel, sessionCost, sessionTokens) } - return "Today: \(sessionCost)" + return "\(sessionLabel): \(sessionCost)" }() - let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let monthCost = snapshot.last30DaysCostUSD.map { + UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) + } ?? "—" let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) let monthTokens = monthTokensValue.map { UsageFormatter.tokenCountString($0) } - let windowLabel = Self.costHistoryWindowLabel(days: snapshot.historyDays) + let windowLabel = snapshot.historyLabel ?? Self.costHistoryWindowLabel(days: snapshot.historyDays) let monthLine: String = { if let monthTokens { - return "\(windowLabel): \(monthCost) · \(monthTokens) tokens" + return String(format: L("%@: %@ · %@ tokens"), windowLabel, monthCost, monthTokens) } return "\(windowLabel): \(monthCost)" }() @@ -68,27 +77,31 @@ extension UsageMenuCardView.Model { static func tokenUsageHint(provider: UsageProvider) -> String? { switch provider { case .codex: - "Estimated from local Codex logs for the selected account." + L("Estimated from local Codex logs for the selected account.") case .claude: UsageFormatter.costEstimateHint(provider: provider) case .vertexai: - UsageFormatter.costEstimateHint + L("cost_estimate_hint") case .bedrock: - "Reported by AWS Cost Explorer; daily billing data can lag." + L("AWS Cost Explorer billing can lag.") + case .openai: + L("Reported by OpenAI Admin API organization usage.") + case .mistral: + L("Reported by Mistral billing usage.") default: nil } } static func costHistoryWindowLabel(days: Int) -> String { - days == 1 ? "Today" : "Last \(days) days" + days == 1 ? L("Today") : String(format: L("Last %d days"), days) } - private static func bedrockLatestBillingDayLabel(from snapshot: CostUsageTokenSnapshot) -> String { + private static func latestBillingDayLabel(from snapshot: CostUsageTokenSnapshot) -> String { guard let entry = bedrockLatestBillingDay(from: snapshot.daily), let displayDate = bedrockDisplayDate(from: entry.date) - else { return "Latest billing day" } - return "Latest billing day (\(displayDate))" + else { return L("Latest billing day") } + return String(format: L("Latest billing day (%@)"), displayDate) } private static func bedrockLatestBillingDay(from entries: [CostUsageDailyReport.Entry]) @@ -138,26 +151,26 @@ extension UsageMenuCardView.Model { if provider == .factory, cost.period == "Extra usage balance" { let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) return ProviderCostSection( - title: "Extra usage", + title: L("Extra usage"), percentUsed: nil, - spendLine: "Balance: \(balance)", + spendLine: "\(L("Balance")): \(balance)", percentLine: nil) } if provider == .opencodego, cost.period == "Zen balance" { let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) return ProviderCostSection( - title: "Zen balance", + title: L("Zen balance"), percentUsed: nil, - spendLine: "Balance: \(balance)", + spendLine: "\(L("Balance")): \(balance)", percentLine: nil) } if provider == .openai || provider == .claude, cost.limit <= 0 { let spend = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - let periodLabel = cost.period ?? "Last 30 days" + let periodLabel = Self.localizedPeriodLabel(cost.period ?? "Last 30 days") return ProviderCostSection( - title: "API spend", + title: L("API spend"), percentUsed: nil, spendLine: "\(periodLabel): \(spend)", percentLine: nil) @@ -170,23 +183,37 @@ extension UsageMenuCardView.Model { let title: String if cost.currencyCode == "Quota" { - title = "Quota usage" + title = L("Quota usage") used = String(format: "%.0f", cost.used) limit = String(format: "%.0f", cost.limit) } else { - title = "Extra usage" + title = L("Extra usage") used = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) limit = UsageFormatter.currencyString(cost.limit, currencyCode: cost.currencyCode) } let percentUsed = Self.clamped((cost.used / cost.limit) * 100) - let periodLabel = cost.period ?? "This month" + let periodLabel = Self.localizedPeriodLabel(cost.period ?? "This month") return ProviderCostSection( title: title, percentUsed: percentUsed, spendLine: "\(periodLabel): \(used) / \(limit)", - percentLine: String(format: "%.0f%% used", min(100, max(0, percentUsed)))) + percentLine: String(format: L("%.0f%% used"), min(100, max(0, percentUsed)))) + } + + private static func localizedPeriodLabel(_ label: String) -> String { + let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines) + switch trimmed.lowercased() { + case "last 30 days": + return L("Last 30 days") + case "this month": + return L("This month") + case "today": + return L("Today") + default: + return L(trimmed) + } } static func clamped(_ value: Double) -> Double { diff --git a/Sources/CodexBar/MenuCardView+Kiro.swift b/Sources/CodexBar/MenuCardView+Kiro.swift index f7bea338..f9f61a9c 100644 --- a/Sources/CodexBar/MenuCardView+Kiro.swift +++ b/Sources/CodexBar/MenuCardView+Kiro.swift @@ -8,13 +8,13 @@ extension UsageMenuCardView.Model { .trimmingCharacters(in: .whitespacesAndNewlines), !authMethod.isEmpty { - notes.append("Auth: \(authMethod)") + notes.append("\(L("Auth")): \(authMethod)") } if let overages = input.snapshot?.kiroUsage?.overagesStatus? .trimmingCharacters(in: .whitespacesAndNewlines), !overages.isEmpty { - notes.append("Overages: \(overages)") + notes.append("\(L("Overages")): \(overages)") } let overagesEnabled = input.snapshot?.kiroUsage?.overagesStatus? .trimmingCharacters(in: .whitespacesAndNewlines) @@ -23,12 +23,13 @@ extension UsageMenuCardView.Model { if overagesEnabled, let overageCreditsUsed = input.snapshot?.kiroUsage?.overageCreditsUsed { - notes.append("Overage usage: \(UsageFormatter.kiroCreditNumber(overageCreditsUsed)) credits") + notes.append( + "\(L("Overage usage")): \(UsageFormatter.kiroCreditNumber(overageCreditsUsed)) \(L("credits"))") } if overagesEnabled, let estimatedOverageCostUSD = input.snapshot?.kiroUsage?.estimatedOverageCostUSD { - notes.append("Overage cost: \(UsageFormatter.usdString(estimatedOverageCostUSD))") + notes.append("\(L("Overage cost")): \(UsageFormatter.usdString(estimatedOverageCostUSD))") } return notes } diff --git a/Sources/CodexBar/MenuCardView+MiniMax.swift b/Sources/CodexBar/MenuCardView+MiniMax.swift index bf0b57e1..dda62d32 100644 --- a/Sources/CodexBar/MenuCardView+MiniMax.swift +++ b/Sources/CodexBar/MenuCardView+MiniMax.swift @@ -9,12 +9,18 @@ extension UsageMenuCardView.Model { return services.enumerated().map { index, service in let used = service.usage let displayPercent = min(100, max(0, service.percent)) - let usageLabel = "Usage: \(used.formatted()) / \(service.limit.formatted())" - let usedLabel = "Used \(String(format: "%.0f%%", displayPercent))" - let title = if service.displayName == "Text Generation", textGenerationCount > 1 { - "Text Generation · \(Self.displayWindowBadge(for: service.windowType))" + let usageLabel = String( + format: L("minimax_usage_amount_format"), + used.formatted(), + service.limit.formatted()) + let usedLabel = String( + format: L("minimax_used_percent_format"), + String(format: "%.0f%%", displayPercent)) + let localizedName = Self.localizedMiniMaxServiceName(service.displayName) + let title = if localizedName == L("minimax_service_text_generation"), textGenerationCount > 1 { + "\(L("minimax_service_text_generation")) · \(Self.displayWindowBadge(for: service.windowType))" } else { - service.displayName + localizedName } return Metric( @@ -22,7 +28,7 @@ extension UsageMenuCardView.Model { title: title, percent: displayPercent, percentStyle: percentStyle, - resetText: service.resetDescription, + resetText: Self.localizedMiniMaxResetDescription(service.resetDescription), detailText: service.timeRange, detailLeftText: usageLabel, detailRightText: usedLabel, @@ -37,17 +43,45 @@ extension UsageMenuCardView.Model { let normalized = trimmed.lowercased() if normalized == "weekly" { - return "Weekly" + return L("Weekly") } if normalized == "5 hours" || normalized == "5 hour" || normalized == "5h" { return "5h" } if normalized == "today" { - return "Today" + return L("Today") } if normalized == "daily" { - return "Daily" + return L("Daily") } return trimmed.isEmpty ? windowType : trimmed } + + private static func localizedMiniMaxResetDescription(_ text: String) -> String { + let prefix = "Resets in " + guard text.hasPrefix(prefix) else { return text } + let rest = String(text.dropFirst(prefix.count)) + return L("Resets in %@", rest) + } + + private static func localizedMiniMaxServiceName(_ raw: String) -> String { + switch raw { + case "Text Generation", "text_generation": + L("minimax_service_text_generation") + case "Text to Speech", "text_to_speech": + L("minimax_service_text_to_speech") + case "Music Generation", "music_generation": + L("minimax_service_music_generation") + case "Image Generation", "image_generation": + L("minimax_service_image_generation") + case "lyrics_generation": + L("minimax_service_lyrics_generation") + case "coding-plan-vlm": + L("minimax_service_coding_plan_vlm") + case "coding-plan-search": + L("minimax_service_coding_plan_search") + default: + raw + } + } } diff --git a/Sources/CodexBar/MenuCardView+ModelHelpers.swift b/Sources/CodexBar/MenuCardView+ModelHelpers.swift index b4ff75f7..973ef05c 100644 --- a/Sources/CodexBar/MenuCardView+ModelHelpers.swift +++ b/Sources/CodexBar/MenuCardView+ModelHelpers.swift @@ -48,11 +48,11 @@ extension UsageMenuCardView.Model { static func placeholder(input: Input) -> String? { if self.shouldShowRateLimitsUnavailablePlaceholder(input: input) { - return "Limits not available" + return L("Limits not available") } if input.snapshot == nil, !input.isRefreshing, input.lastError == nil { - return "No usage yet" + return L("No usage yet") } return nil @@ -132,4 +132,196 @@ extension UsageMenuCardView.Model { pacePercent: pacePercent, paceOnTop: paceOnTop) } + + static func antigravityMetrics(input: Input, snapshot: UsageSnapshot) -> [Metric] { + let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left + var metrics = [ + Self.antigravityMetric( + id: "primary", + title: L(input.metadata.sessionLabel), + window: snapshot.primary, + input: input, + percentStyle: percentStyle), + Self.antigravityMetric( + id: "secondary", + title: L(input.metadata.weeklyLabel), + window: snapshot.secondary, + input: input, + percentStyle: percentStyle), + Self.antigravityMetric( + id: "tertiary", + title: input.metadata.opusLabel.map(L) ?? L("Gemini Flash"), + window: snapshot.tertiary, + input: input, + percentStyle: percentStyle), + ] + metrics.append(contentsOf: Self.extraRateWindowMetrics( + snapshot: snapshot, + input: input, + percentStyle: percentStyle)) + return metrics + } + + static func extraRateWindowMetrics( + snapshot: UsageSnapshot, + input: Input, + percentStyle: PercentStyle) -> [Metric] + { + guard let extraRateWindows = snapshot.extraRateWindows else { return [] } + // Codex additional limits (e.g. Codex Spark) are optional extra usage and follow the + // "optional credits and extra usage" setting. Other providers' extra windows (Antigravity + // per-model quotas, Factory core windows, etc.) are core data and must always render. + if input.provider == .codex, !input.showOptionalCreditsAndExtraUsage { + return [] + } + return extraRateWindows.map { namedWindow in + Metric( + id: namedWindow.id, + title: namedWindow.title, + percent: Self.clamped( + input.usageBarsShowUsed + ? namedWindow.window.usedPercent + : namedWindow.window.remainingPercent), + percentStyle: percentStyle, + resetText: Self.resetText( + for: namedWindow.window, + style: input.resetTimeDisplayStyle, + now: input.now), + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true) + } + } + + static func antigravityMetric( + id: String, + title: String, + window: RateWindow?, + input: Input, + percentStyle: PercentStyle) -> Metric + { + guard let window else { + let placeholderPercent = input.usageBarsShowUsed ? 100.0 : 0.0 + return Metric( + id: id, + title: title, + percent: placeholderPercent, + percentStyle: percentStyle, + statusText: nil, + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true) + } + let percent = input.usageBarsShowUsed ? window.usedPercent : window.remainingPercent + return Metric( + id: id, + title: title, + percent: Self.clamped(percent), + percentStyle: percentStyle, + resetText: Self.resetText(for: window, style: input.resetTimeDisplayStyle, now: input.now), + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true) + } + + static func zaiLimitDetailText(limit: ZaiLimitEntry?) -> String? { + guard let limit else { return nil } + + if let currentValue = limit.currentValue, + let usage = limit.usage, + let remaining = limit.remaining + { + let currentStr = UsageFormatter.tokenCountString(currentValue) + let usageStr = UsageFormatter.tokenCountString(usage) + let remainingStr = UsageFormatter.tokenCountString(remaining) + return String(format: L("%@ / %@ (%@ remaining)"), currentStr, usageStr, remainingStr) + } + + return nil + } + + static func openRouterQuotaDetail(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { + guard provider == .openrouter, + let usage = snapshot.openRouterUsage, + usage.hasValidKeyQuota, + let keyRemaining = usage.keyRemaining, + let keyLimit = usage.keyLimit + else { + return nil + } + + let remaining = UsageFormatter.usdString(keyRemaining) + let limit = UsageFormatter.usdString(keyLimit) + return String(format: L("%@/%@ left"), remaining, limit) + } + + static func syntheticRegenDetail( + weekly: RateWindow, + cost: ProviderCostSnapshot?, + now: Date, + showUsed: Bool) -> (resetText: String, pace: PaceDetail)? + { + guard let cost, + cost.limit > 0, + let nextRegenAmount = cost.nextRegenAmount, + nextRegenAmount > 0, + let resetsAt = weekly.resetsAt + else { return nil } + + let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now) + let resetText = String(format: L("Regenerates %@"), countdown) + + let nextRegenPercent = (nextRegenAmount / cost.limit) * 100 + let afterNextRegenRemaining = min(100, weekly.remainingPercent + nextRegenPercent) + let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining + let suffix = showUsed ? L("used after next regen") : L("after next regen") + let ticksToFull = max(0, cost.used) / nextRegenAmount + let left = String(format: "%.0f%% %@", afterNextRegen, suffix) + let right = if ticksToFull <= 0.1 { + L("Near full") + } else if ticksToFull < 1.5 { + L("Full in ~1 regen") + } else { + String(format: L("Full in ~%.0f regens"), ceil(ticksToFull)) + } + return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true)) + } + + static func syntheticRollingRegenDetail( + window: RateWindow, + now: Date, + showUsed: Bool) -> (resetText: String, pace: PaceDetail)? + { + guard let resetsAt = window.resetsAt, + let nextRegenPercent = window.nextRegenPercent, + nextRegenPercent > 0 + else { return nil } + + let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now) + let resetText = String(format: L("Regenerates %@"), countdown) + + let afterNextRegenRemaining = min(100, window.remainingPercent + nextRegenPercent) + let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining + let suffix = showUsed ? L("used after next regen") : L("after next regen") + let left = String(format: "%.0f%% %@", afterNextRegen, suffix) + + let missingPercent = max(0, window.usedPercent) + let ticksToFull = missingPercent / nextRegenPercent + let right = if ticksToFull <= 0.1 { + L("Near full") + } else if ticksToFull < 1.5 { + L("Full in ~1 regen") + } else { + String(format: L("Full in ~%.0f regens"), ceil(ticksToFull)) + } + + return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true)) + } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 499be19e..3c16bb85 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -11,15 +11,15 @@ struct UsageMenuCardView: View { var labelSuffix: String { switch self { - case .left: "left" - case .used: "used" + case .left: L("usage_percent_suffix_left") + case .used: L("usage_percent_suffix_used") } } var accessibilityLabel: String { switch self { - case .left: "Usage remaining" - case .used: "Usage used" + case .left: L("Usage remaining") + case .used: L("Usage used") } } } @@ -121,7 +121,7 @@ struct UsageMenuCardView: View { static func popupMetricTitle(provider: UsageProvider, metric: Model.Metric) -> String { if provider == .openrouter, metric.id == "primary" { - return "API key limit" + return L("API key limit") } return metric.title } @@ -190,7 +190,7 @@ struct UsageMenuCardView: View { } if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("cost_header_estimated") + Text(L("cost_header_estimated")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -323,7 +323,7 @@ private struct CopyIconButton: View { .frame(width: 18, height: 18) } .buttonStyle(CopyIconButtonStyle(isHighlighted: self.isHighlighted)) - .accessibilityLabel(self.didCopy ? "Copied" : "Copy error") + .accessibilityLabel(self.didCopy ? L("Copied") : L("Copy error")) } private func copyToPasteboard() { @@ -347,7 +347,7 @@ private struct ProviderCostContent: View { UsageProgressBar( percent: percentUsed, tint: self.progressColor, - accessibilityLabel: "Extra usage spent") + accessibilityLabel: L("Extra usage spent")) } HStack(alignment: .firstTextBaseline) { Text(self.section.spendLine) @@ -561,19 +561,19 @@ private struct CreditsBarContent: View { private var scaleText: String { let scale = UsageFormatter.tokenCountString(Int(Self.fullScaleTokens)) - return "\(scale) tokens" + return "\(scale) \(L("tokens"))" } var body: some View { VStack(alignment: .leading, spacing: 6) { - Text("Credits") + Text(L("Credits")) .font(.body) .fontWeight(.medium) if let percentLeft { UsageProgressBar( percent: percentLeft, tint: self.progressColor, - accessibilityLabel: "Credits remaining") + accessibilityLabel: L("Credits remaining")) HStack(alignment: .firstTextBaseline) { Text(self.creditsText) .font(.caption) @@ -614,7 +614,7 @@ struct UsageMenuCardCostSectionView: View { VStack(alignment: .leading, spacing: 10) { if let tokenUsage = self.model.tokenUsage { VStack(alignment: .leading, spacing: 6) { - Text("cost_header_estimated") + Text(L("cost_header_estimated")) .font(.body) .fontWeight(.medium) Text(tokenUsage.sessionLine) @@ -697,6 +697,7 @@ extension UsageMenuCardView.Model { let hidePersonalInfo: Bool let weeklyPace: UsagePace? let quotaWarningThresholds: [QuotaWarningWindow: [Int]] + let workDaysPerWeek: Int? let now: Date init( @@ -722,6 +723,7 @@ extension UsageMenuCardView.Model { hidePersonalInfo: Bool, weeklyPace: UsagePace? = nil, quotaWarningThresholds: [QuotaWarningWindow: [Int]] = [:], + workDaysPerWeek: Int? = nil, now: Date) { self.provider = provider @@ -746,6 +748,7 @@ extension UsageMenuCardView.Model { self.hidePersonalInfo = hidePersonalInfo self.weeklyPace = weeklyPace self.quotaWarningThresholds = quotaWarningThresholds + self.workDaysPerWeek = workDaysPerWeek self.now = now } } @@ -780,10 +783,11 @@ extension UsageMenuCardView.Model { } else { Self.providerCostSection(provider: input.provider, cost: input.snapshot?.providerCost) } + let tokenUsageSnapshot = Self.tokenUsageSnapshot(input: input) let tokenUsage = Self.tokenUsageSection( provider: input.provider, enabled: input.tokenCostUsageEnabled, - snapshot: input.tokenSnapshot, + snapshot: tokenUsageSnapshot, error: input.tokenError) let subtitle = Self.subtitle( snapshot: input.snapshot, @@ -828,15 +832,15 @@ extension UsageMenuCardView.Model { resolvedSource == "cli", !notes.contains(where: { $0.caseInsensitiveCompare("Using CLI fallback") == .orderedSame }) { - notes.append("Using CLI fallback") + notes.append(L("Using CLI fallback")) } return notes } if input.provider == .mimo, input.snapshot != nil { return [ - "Balance updates in near-real time (up to 5 min lag)", - "Daily billing data finalizes at 07:00 UTC", + L("Balance updates in near-real time (up to 5 min lag)"), + L("Daily billing data finalizes at 07:00 UTC"), ] } @@ -855,9 +859,9 @@ extension UsageMenuCardView.Model { case .available: break case .noLimitConfigured: - notes.append("No limit set for the API key") + notes.append(L("No limit set for the API key")) case .unavailable: - notes.append("API key limit unavailable right now") + notes.append(L("API key limit unavailable right now")) } return notes } @@ -865,10 +869,10 @@ extension UsageMenuCardView.Model { private static func openRouterSpendNotes(_ usage: OpenRouterUsageSnapshot) -> [String] { var parts: [String] = [] if let daily = usage.keyUsageDaily { - parts.append("Today: \(Self.openRouterCurrencyString(daily))") + parts.append("\(L("Today")): \(Self.openRouterCurrencyString(daily))") } if let weekly = usage.keyUsageWeekly { - parts.append("This week: \(Self.openRouterCurrencyString(weekly))") + parts.append("\(L("This week")): \(Self.openRouterCurrencyString(weekly))") } guard !parts.isEmpty else { return [] } return [parts.joined(separator: " · ")] @@ -972,14 +976,14 @@ extension UsageMenuCardView.Model { } if isRefreshing, snapshot == nil { - return ("Refreshing...", .loading) + return ("\(L("Refreshing"))…", .loading) } if let updated = snapshot?.updatedAt { return (UsageFormatter.updatedString(from: updated, now: now), .info) } - return ("Not fetched yet", .info) + return (L("Not fetched yet"), .info) } private struct RedactedText { @@ -1092,27 +1096,10 @@ extension UsageMenuCardView.Model { thresholds: input.quotaWarningThresholds[.weekly], showUsed: input.usageBarsShowUsed))) } - if let extraRateWindows = snapshot.extraRateWindows { - metrics.append(contentsOf: extraRateWindows.map { namedWindow in - Metric( - id: namedWindow.id, - title: namedWindow.title, - percent: Self.clamped( - input.usageBarsShowUsed - ? namedWindow.window.usedPercent - : namedWindow.window.remainingPercent), - percentStyle: percentStyle, - resetText: Self.resetText( - for: namedWindow.window, - style: input.resetTimeDisplayStyle, - now: input.now), - detailText: nil, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true) - }) - } + metrics.append(contentsOf: Self.extraRateWindowMetrics( + snapshot: snapshot, + input: input, + percentStyle: percentStyle)) if input.provider == .kilo, metrics.contains(where: { $0.id == "primary" }), metrics.contains(where: { $0.id == "secondary" }) @@ -1136,7 +1123,7 @@ extension UsageMenuCardView.Model { } metrics.append(Metric( id: "code-review", - title: "Code review", + title: L("Code review"), percent: Self.clamped(percent), percentStyle: percentStyle, resetText: resetText, @@ -1154,12 +1141,15 @@ extension UsageMenuCardView.Model { snapshot: UsageSnapshot) -> (primary: String, secondary: String, tertiary: String, showsTertiary: Bool) { if input.provider == .factory, snapshot.tertiary != nil { - return ("5-hour", "Weekly", "Monthly", true) + return ("5-hour", L("Weekly"), L("Monthly"), true) } + let primaryLabel = input.provider == .grok + ? GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? input.metadata.sessionLabel + : input.metadata.sessionLabel return ( - input.metadata.sessionLabel, - input.metadata.weeklyLabel, - input.metadata.opusLabel ?? "Sonnet", + L(primaryLabel), + L(input.metadata.weeklyLabel), + input.metadata.opusLabel.map(L) ?? L("Sonnet"), input.metadata.supportsOpus) } @@ -1204,7 +1194,7 @@ extension UsageMenuCardView.Model { { let remaining = UsageFormatter.kiroCreditNumber(kiroUsage.creditsRemaining) let total = UsageFormatter.kiroCreditNumber(kiroUsage.creditsTotal) - primaryDetailLeft = "\(remaining) of \(total) credits left" + primaryDetailLeft = String(format: L("%@ of %@ credits left"), remaining, total) } if input.provider == .alibaba || input.provider == .alibabatokenplan || input.provider == .mistral || input .provider == .manus, @@ -1272,7 +1262,7 @@ extension UsageMenuCardView.Model { } return Metric( id: "primary", - title: title ?? input.metadata.sessionLabel, + title: title ?? L(input.metadata.sessionLabel), percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, @@ -1326,7 +1316,7 @@ extension UsageMenuCardView.Model { let remainingText = UsageFormatter.kiroCreditNumber(remaining) let totalText = UsageFormatter.kiroCreditNumber(total) paceDetail = PaceDetail( - leftLabel: "\(remainingText) of \(totalText) bonus credits left", + leftLabel: String(format: L("%@ of %@ bonus credits left"), remainingText, totalText), rightLabel: nil, pacePercent: nil, paceOnTop: true) @@ -1374,7 +1364,7 @@ extension UsageMenuCardView.Model { } return Metric( id: "secondary", - title: title ?? input.metadata.weeklyLabel, + title: title ?? L(input.metadata.weeklyLabel), percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, resetText: weeklyResetText, @@ -1383,9 +1373,7 @@ extension UsageMenuCardView.Model { detailRightText: paceDetail?.rightLabel, pacePercent: paceDetail?.pacePercent, paceOnTop: paceDetail?.paceOnTop ?? true, - warningMarkerPercents: Self.warningMarkerPercents( - thresholds: input.quotaWarningThresholds[.weekly], - showUsed: input.usageBarsShowUsed)) + warningMarkerPercents: Self.weeklyMarkerPercents(input: input, windowMinutes: weekly.windowMinutes)) } private static func codexRateMetrics( @@ -1401,7 +1389,7 @@ extension UsageMenuCardView.Model { let paceDetail: PaceDetail? switch lane { case .session: - title = input.metadata.sessionLabel + title = L(input.metadata.sessionLabel) id = "primary" paceDetail = Self.sessionPaceDetail( provider: input.provider, @@ -1409,7 +1397,7 @@ extension UsageMenuCardView.Model { now: input.now, showUsed: input.usageBarsShowUsed) case .weekly: - title = input.metadata.weeklyLabel + title = L(input.metadata.weeklyLabel) id = "secondary" paceDetail = Self.weeklyPaceDetail( window: window, @@ -1431,164 +1419,11 @@ extension UsageMenuCardView.Model { detailRightText: paceDetail?.rightLabel, pacePercent: paceDetail?.pacePercent, paceOnTop: paceDetail?.paceOnTop ?? true, - warningMarkerPercents: Self.warningMarkerPercents( - thresholds: input.quotaWarningThresholds[lane.quotaWarningWindow], - showUsed: input.usageBarsShowUsed)) - } - } - - private static func antigravityMetrics(input: Input, snapshot: UsageSnapshot) -> [Metric] { - let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left - return [ - Self.antigravityMetric( - id: "primary", - title: input.metadata.sessionLabel, - window: snapshot.primary, - input: input, - percentStyle: percentStyle), - Self.antigravityMetric( - id: "secondary", - title: input.metadata.weeklyLabel, - window: snapshot.secondary, - input: input, - percentStyle: percentStyle), - Self.antigravityMetric( - id: "tertiary", - title: input.metadata.opusLabel ?? "Gemini Flash", - window: snapshot.tertiary, - input: input, - percentStyle: percentStyle), - ] - } - - private static func antigravityMetric( - id: String, - title: String, - window: RateWindow?, - input: Input, - percentStyle: PercentStyle) -> Metric - { - guard let window else { - let placeholderPercent = input.usageBarsShowUsed ? 100.0 : 0.0 - return Metric( - id: id, - title: title, - percent: placeholderPercent, - percentStyle: percentStyle, - statusText: nil, - resetText: nil, - detailText: nil, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true) + warningMarkerPercents: Self.codexLaneMarkerPercents( + input: input, + lane: lane, + windowMinutes: window.windowMinutes)) } - let percent = input.usageBarsShowUsed ? window.usedPercent : window.remainingPercent - return Metric( - id: id, - title: title, - percent: Self.clamped(percent), - percentStyle: percentStyle, - resetText: Self.resetText(for: window, style: input.resetTimeDisplayStyle, now: input.now), - detailText: nil, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true) - } - - private static func zaiLimitDetailText(limit: ZaiLimitEntry?) -> String? { - guard let limit else { return nil } - - if let currentValue = limit.currentValue, - let usage = limit.usage, - let remaining = limit.remaining - { - let currentStr = UsageFormatter.tokenCountString(currentValue) - let usageStr = UsageFormatter.tokenCountString(usage) - let remainingStr = UsageFormatter.tokenCountString(remaining) - return "\(currentStr) / \(usageStr) (\(remainingStr) remaining)" - } - - return nil - } - - private static func openRouterQuotaDetail(provider: UsageProvider, snapshot: UsageSnapshot) -> String? { - guard provider == .openrouter, - let usage = snapshot.openRouterUsage, - usage.hasValidKeyQuota, - let keyRemaining = usage.keyRemaining, - let keyLimit = usage.keyLimit - else { - return nil - } - - let remaining = UsageFormatter.usdString(keyRemaining) - let limit = UsageFormatter.usdString(keyLimit) - return "\(remaining)/\(limit) left" - } - - private static func syntheticRegenDetail( - weekly: RateWindow, - cost: ProviderCostSnapshot?, - now: Date, - showUsed: Bool) -> (resetText: String, pace: PaceDetail)? - { - guard let cost, - cost.limit > 0, - let nextRegenAmount = cost.nextRegenAmount, - nextRegenAmount > 0, - let resetsAt = weekly.resetsAt - else { return nil } - - let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now) - let resetText = "Regenerates \(countdown)" - - let nextRegenPercent = (nextRegenAmount / cost.limit) * 100 - let afterNextRegenRemaining = min(100, weekly.remainingPercent + nextRegenPercent) - let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining - let suffix = showUsed ? "used after next regen" : "after next regen" - let ticksToFull = max(0, cost.used) / nextRegenAmount - let left = String(format: "%.0f%% %@", afterNextRegen, suffix) - let right = if ticksToFull <= 0.1 { - "Near full" - } else if ticksToFull < 1.5 { - "Full in ~1 regen" - } else { - String(format: "Full in ~%.0f regens", ceil(ticksToFull)) - } - return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true)) - } - - private static func syntheticRollingRegenDetail( - window: RateWindow, - now: Date, - showUsed: Bool) -> (resetText: String, pace: PaceDetail)? - { - guard let resetsAt = window.resetsAt, - let nextRegenPercent = window.nextRegenPercent, - nextRegenPercent > 0 - else { return nil } - - let countdown = UsageFormatter.resetCountdownDescription(from: resetsAt, now: now) - let resetText = "Regenerates \(countdown)" - - let afterNextRegenRemaining = min(100, window.remainingPercent + nextRegenPercent) - let afterNextRegen = showUsed ? max(0, 100 - afterNextRegenRemaining) : afterNextRegenRemaining - let suffix = showUsed ? "used after next regen" : "after next regen" - let left = String(format: "%.0f%% %@", afterNextRegen, suffix) - - let missingPercent = max(0, window.usedPercent) - let ticksToFull = missingPercent / nextRegenPercent - let right = if ticksToFull <= 0.1 { - "Near full" - } else if ticksToFull < 1.5 { - "Full in ~1 regen" - } else { - String(format: "Full in ~%.0f regens", ceil(ticksToFull)) - } - - return (resetText, PaceDetail(leftLabel: left, rightLabel: right, pacePercent: nil, paceOnTop: true)) } private static func dashboardHint(error: String?) -> String? { diff --git a/Sources/CodexBar/MenuContent.swift b/Sources/CodexBar/MenuContent.swift index b9204b1f..264eeb1e 100644 --- a/Sources/CodexBar/MenuContent.swift +++ b/Sources/CodexBar/MenuContent.swift @@ -183,15 +183,15 @@ struct StatusIconView: View { private var accessibilityValue: String { let snapshot = self.store.snapshot(for: self.provider) guard let snap = snapshot else { - return "No data" + return L("No data") } let remaining = IconRemainingResolver.resolvedRemaining( snapshot: snap, style: self.store.style(for: self.provider)) let primary = remaining.primary - let percent = primary.map { "\(Int($0 * 100)) percent remaining" } ?? "Unknown" + let percent = primary.map { String(format: L("%d percent remaining"), Int($0 * 100)) } ?? L("Unknown") let stale = self.store.isStale(provider: self.provider) - return stale ? "\(percent), stale data" : percent + return stale ? "\(percent), \(L("stale data"))" : percent } private var icon: NSImage { diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index fd711e07..20788c2c 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -113,7 +113,7 @@ struct MenuDescriptor { sections.append(accountSection) } } else { - sections.append(Section(entries: [.text("No usage configured.", .secondary)])) + sections.append(Section(entries: [.text(L("No usage configured."), .secondary)])) } } @@ -240,7 +240,7 @@ struct MenuDescriptor { Self.appendProviderUsageSummaries(entries: &entries, snapshot: snap) } else { - entries.append(.text("No usage yet", .secondary)) + entries.append(.text(L("No usage yet"), .secondary)) } let usageContext = ProviderMenuUsageContext( @@ -263,7 +263,7 @@ struct MenuDescriptor { if cost.currencyCode == "Quota" { let used = String(format: "%.0f", cost.used) let limit = String(format: "%.0f", cost.limit) - entries.append(.text("Quota: \(used) / \(limit)", .primary)) + entries.append(.text("\(L("Quota")): \(used) / \(limit)", .primary)) } } if let openAIAPIUsage = snapshot.openAIAPIUsage { @@ -290,19 +290,19 @@ struct MenuDescriptor { let historyLabel = usage.historyWindowLabel entries.append(.text( - "Today: \(UsageFormatter.usdString(today.costUSD)) · " + - "\(UsageFormatter.tokenCountString(today.totalTokens)) tokens", + "\(L("Today")): \(UsageFormatter.usdString(today.costUSD)) · " + + "\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))", .secondary)) entries.append(.text( "7d: \(UsageFormatter.usdString(last7.costUSD)) · " + - "\(UsageFormatter.tokenCountString(last7.requests)) requests", + "\(UsageFormatter.tokenCountString(last7.requests)) \(L("requests"))", .secondary)) entries.append(.text( "\(historyLabel): \(UsageFormatter.usdString(last30.costUSD)) · " + - "\(UsageFormatter.tokenCountString(last30.requests)) requests", + "\(UsageFormatter.tokenCountString(last30.requests)) \(L("requests"))", .secondary)) if let topModel = usage.topModels.first?.name { - entries.append(.text("Top model: \(topModel)", .secondary)) + entries.append(.text("\(L("Top model")): \(topModel)", .secondary)) } } @@ -315,19 +315,19 @@ struct MenuDescriptor { let last30 = usage.last30Days entries.append(.text( - "Today: \(UsageFormatter.usdString(today.costUSD)) · " + - "\(UsageFormatter.tokenCountString(today.totalTokens)) tokens", + "\(L("Today")): \(UsageFormatter.usdString(today.costUSD)) · " + + "\(UsageFormatter.tokenCountString(today.totalTokens)) \(L("tokens"))", .secondary)) entries.append(.text( "7d: \(UsageFormatter.usdString(last7.costUSD)) · " + - "\(UsageFormatter.tokenCountString(last7.totalTokens)) tokens", + "\(UsageFormatter.tokenCountString(last7.totalTokens)) \(L("tokens"))", .secondary)) entries.append(.text( "30d: \(UsageFormatter.usdString(last30.costUSD)) · " + - "\(UsageFormatter.tokenCountString(last30.totalTokens)) tokens", + "\(UsageFormatter.tokenCountString(last30.totalTokens)) \(L("tokens"))", .secondary)) if let topModel = usage.topModels.first?.name { - entries.append(.text("Top model: \(topModel)", .secondary)) + entries.append(.text("\(L("Top model")): \(topModel)", .secondary)) } } @@ -336,13 +336,13 @@ struct MenuDescriptor { usage: OpenRouterUsageSnapshot) { if let daily = usage.keyUsageDaily { - entries.append(.text("Today: \(UsageFormatter.usdString(daily))", .secondary)) + entries.append(.text("\(L("Today")): \(UsageFormatter.usdString(daily))", .secondary)) } if let weekly = usage.keyUsageWeekly { - entries.append(.text("Week: \(UsageFormatter.usdString(weekly))", .secondary)) + entries.append(.text("\(L("Week")): \(UsageFormatter.usdString(weekly))", .secondary)) } if let monthly = usage.keyUsageMonthly { - entries.append(.text("Month: \(UsageFormatter.usdString(monthly))", .secondary)) + entries.append(.text("\(L("Month")): \(UsageFormatter.usdString(monthly))", .secondary)) } } @@ -353,17 +353,17 @@ struct MenuDescriptor { let latest = usage.daily.last if let latest { entries.append(.text( - "Latest: \(usage.currencySymbol)\(String(format: "%.4f", max(0, latest.cost))) · " + - "\(UsageFormatter.tokenCountString(latest.totalTokens)) tokens", + "\(L("Latest")): \(usage.currencySymbol)\(String(format: "%.4f", max(0, latest.cost))) · " + + "\(UsageFormatter.tokenCountString(latest.totalTokens)) \(L("tokens"))", .secondary)) } let totalTokens = usage.totalInputTokens + usage.totalCachedTokens + usage.totalOutputTokens entries.append(.text( - "Month: \(usage.currencySymbol)\(String(format: "%.4f", max(0, usage.totalCost))) · " + - "\(UsageFormatter.tokenCountString(totalTokens)) tokens", + "\(L("Month")): \(usage.currencySymbol)\(String(format: "%.4f", max(0, usage.totalCost))) · " + + "\(UsageFormatter.tokenCountString(totalTokens)) \(L("tokens"))", .secondary)) if let top = Self.topMistralModel(from: usage.daily) { - entries.append(.text("Top model: \(top)", .secondary)) + entries.append(.text("\(L("Top model")): \(top)", .secondary)) } } @@ -413,29 +413,29 @@ struct MenuDescriptor { let redactedEmail = PersonalInfoRedactor.redactEmail(emailText, isEnabled: hidePersonalInfo) if let emailText, !emailText.isEmpty { - entries.append(.text("Account: \(redactedEmail)", .secondary)) + entries.append(.text("\(L("Account")): \(redactedEmail)", .secondary)) } if provider == .kiro { if let plan = snapshot?.kiroUsage?.displayPlanName, !plan.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - entries.append(.text("Plan: \(plan)", .secondary)) + entries.append(.text("\(L("Plan")): \(plan)", .secondary)) } if let loginMethodText, !loginMethodText.isEmpty { - entries.append(.text("Auth: \(loginMethodText)", .secondary)) + entries.append(.text("\(L("Auth")): \(loginMethodText)", .secondary)) } if let overages = snapshot?.kiroUsage?.overagesStatus, !overages.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - entries.append(.text("Overages: \(overages)", .secondary)) + entries.append(.text("\(L("Overages")): \(overages)", .secondary)) } } else if provider == .kilo { let kiloLogin = self.kiloLoginParts(loginMethod: loginMethodText) if let pass = kiloLogin.pass { - entries.append(.text("Plan: \(AccountFormatter.plan(pass, provider: provider))", .secondary)) + entries.append(.text("\(L("Plan")): \(AccountFormatter.plan(pass, provider: provider))", .secondary)) } for detail in kiloLogin.details { - entries.append(.text("Activity: \(detail)", .secondary)) + entries.append(.text("\(L("Activity")): \(detail)", .secondary)) } } else if let loginMethodText, !loginMethodText.isEmpty { if provider == .openrouter || provider == .mimo, @@ -448,19 +448,26 @@ struct MenuDescriptor { options: [.regularExpression]) .trimmingCharacters(in: .whitespacesAndNewlines) let value = balanceValue.isEmpty ? loginMethodText : balanceValue - entries.append(.text("Balance: \(AccountFormatter.plan(value, provider: provider))", .secondary)) + entries.append( + .text("\(L("Balance")): \(AccountFormatter.plan(value, provider: provider))", .secondary)) } else { - entries.append(.text("Plan: \(AccountFormatter.plan(loginMethodText, provider: provider))", .secondary)) + entries.append( + .text( + "\(L("Plan")): \(AccountFormatter.plan(loginMethodText, provider: provider))", + .secondary)) } } if metadata.usesAccountFallback { if emailText?.isEmpty ?? true, let fallbackEmail = fallback.email, !fallbackEmail.isEmpty { let redacted = PersonalInfoRedactor.redactEmail(fallbackEmail, isEnabled: hidePersonalInfo) - entries.append(.text("Account: \(redacted)", .secondary)) + entries.append(.text("\(L("Account")): \(redacted)", .secondary)) } if loginMethodText?.isEmpty ?? true, let fallbackPlan = fallback.plan, !fallbackPlan.isEmpty { - entries.append(.text("Plan: \(AccountFormatter.plan(fallbackPlan, provider: provider))", .secondary)) + entries.append( + .text( + "\(L("Plan")): \(AccountFormatter.plan(fallbackPlan, provider: provider))", + .secondary)) } } @@ -534,7 +541,7 @@ struct MenuDescriptor { } else { let loginAction = self.switchAccountTarget(for: provider, store: store) let hasAccount = self.hasAccount(for: provider, store: store, account: fallbackAccount) - let accountLabel = hasAccount ? "Switch Account..." : "Add Account..." + let accountLabel = hasAccount ? L("Switch Account...") : L("Add Account...") entries.append(.action(accountLabel, loginAction)) } } @@ -552,13 +559,13 @@ struct MenuDescriptor { } if metadata?.dashboardURL != nil { - entries.append(.action("Usage Dashboard", .dashboard)) + entries.append(.action(L("Usage Dashboard"), .dashboard)) } if metadata?.statusPageURL != nil || metadata?.statusLinkURL != nil { - entries.append(.action("Status Page", .statusPage)) + entries.append(.action(L("Status Page"), .statusPage)) } if store.settings.providerChangelogLinksEnabled, metadata?.changelogURL != nil { - entries.append(.action("Changelog", .changelog)) + entries.append(.action(L("Changelog"), .changelog)) } if let statusLine = self.statusLine(for: provider, store: store) { @@ -571,13 +578,13 @@ struct MenuDescriptor { private static func metaSection(updateReady: Bool) -> Section { var entries: [Entry] = [] if updateReady { - entries.append(.action("Update ready, restart now?", .installUpdate)) + entries.append(.action(L("Update ready, restart now?"), .installUpdate)) } entries.append(contentsOf: [ - .action("Refresh", .refresh), - .action("Settings...", .settings), - .action("About CodexBar", .about), - .action("Quit", .quit), + .action(L("Refresh"), .refresh), + .action(L("Settings..."), .settings), + .action(L("About CodexBar"), .about), + .action(L("Quit"), .quit), ]) return Section(entries: entries) } @@ -626,12 +633,15 @@ struct MenuDescriptor { snapshot: UsageSnapshot) -> (primary: String, secondary: String, tertiary: String, showsTertiary: Bool) { if provider == .factory, snapshot.tertiary != nil { - return ("5-hour", "Weekly", "Monthly", true) + return ("5-hour", L("Weekly"), L("Monthly"), true) } + let primaryLabel = provider == .grok + ? GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel + : metadata.sessionLabel return ( - metadata.sessionLabel, - metadata.weeklyLabel, - metadata.opusLabel ?? "Sonnet", + L(primaryLabel), + L(metadata.weeklyLabel), + metadata.opusLabel.map(L) ?? L("Sonnet"), metadata.supportsOpus) } diff --git a/Sources/CodexBar/OpenAIAPIUsageChartMenuView.swift b/Sources/CodexBar/OpenAIAPIUsageChartMenuView.swift deleted file mode 100644 index b11d742f..00000000 --- a/Sources/CodexBar/OpenAIAPIUsageChartMenuView.swift +++ /dev/null @@ -1,306 +0,0 @@ -import Charts -import CodexBarCore -import SwiftUI - -@MainActor -struct OpenAIAPIUsageChartMenuView: View { - private let snapshot: OpenAIAPIUsageSnapshot - private let width: CGFloat - @State private var selectedDay: String? - - init(snapshot: OpenAIAPIUsageSnapshot, width: CGFloat) { - self.snapshot = snapshot - self.width = width - } - - var body: some View { - let model = Self.makeModel(snapshot: self.snapshot) - VStack(alignment: .leading, spacing: 10) { - if model.points.isEmpty { - Text("No OpenAI API usage data.") - .font(.footnote) - .foregroundStyle(.secondary) - } else { - LazyVGrid( - columns: [GridItem(.adaptive(minimum: 88), alignment: .leading)], - alignment: .leading, - spacing: 6) - { - StatPill( - title: "\(model.historyLabel) spend", - value: UsageFormatter.usdString(model.last30.costUSD)) - StatPill( - title: "\(model.historyLabel) tokens", - value: UsageFormatter.tokenCountString(model.last30.totalTokens)) - StatPill( - title: "\(model.historyLabel) requests", - value: UsageFormatter.tokenCountString(model.last30.requests)) - } - - Chart { - ForEach(model.points) { point in - BarMark( - x: .value("Day", point.date, unit: .day), - y: .value("Spend", point.costUSD)) - .foregroundStyle(Self.spendColor) - .cornerRadius(2) - } - if let peak = model.peakSpendPoint { - PointMark( - x: .value("Peak spend day", peak.date, unit: .day), - y: .value("Spend", peak.costUSD)) - .symbolSize(30) - .foregroundStyle(Color(nsColor: .systemYellow)) - } - } - .chartYAxis(.hidden) - .chartXAxis { - AxisMarks(values: model.axisDates) { _ in - AxisGridLine().foregroundStyle(Color.clear) - AxisTick().foregroundStyle(Color.clear) - AxisValueLabel(format: .dateTime.month(.abbreviated).day()) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - } - } - .frame(height: 106) - .accessibilityLabel("OpenAI API spend chart") - .chartOverlay { proxy in - GeometryReader { geo in - ZStack(alignment: .topLeading) { - if let rect = self.selectionBandRect(model: model, proxy: proxy, geo: geo) { - Rectangle() - .fill(Self.selectionBandColor) - .frame(width: rect.width, height: rect.height) - .position(x: rect.midX, y: rect.midY) - .allowsHitTesting(false) - } - MouseLocationReader { location in - self.updateSelection(location: location, model: model, proxy: proxy, geo: geo) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) - } - } - } - - Chart { - ForEach(model.points) { point in - AreaMark( - x: .value("Day", point.date, unit: .day), - y: .value("Tokens", point.totalTokens)) - .interpolationMethod(.catmullRom) - .foregroundStyle(Self.tokenColor.opacity(0.22)) - LineMark( - x: .value("Day", point.date, unit: .day), - y: .value("Tokens", point.totalTokens)) - .interpolationMethod(.catmullRom) - .foregroundStyle(Self.tokenColor) - BarMark( - x: .value("Day", point.date, unit: .day), - y: .value("Requests", point.requests)) - .foregroundStyle(Self.requestColor.opacity(0.32)) - } - } - .chartYAxis(.hidden) - .chartXAxis(.hidden) - .frame(height: 74) - .accessibilityLabel("OpenAI API token and request chart") - - let detail = self.detail(model: model) - VStack(alignment: .leading, spacing: 3) { - Text(detail.primary) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - if let secondary = detail.secondary { - Text(secondary) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - .lineLimit(1) - } - } - - LegendRow(items: [ - (Self.spendColor, "Spend"), - (Self.tokenColor, "Tokens"), - (Self.requestColor, "Requests"), - ]) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading) - } - - private struct Point: Identifiable { - let id: String - let day: String - let date: Date - let costUSD: Double - let requests: Int - let totalTokens: Int - } - - private struct Model { - let points: [Point] - let pointsByDay: [String: Point] - let dayDates: [(day: String, date: Date)] - let axisDates: [Date] - let peakSpendPoint: Point? - let last30: OpenAIAPIUsageSnapshot.Summary - let historyLabel: String - } - - private static let spendColor = Color(red: 0.81, green: 0.56, blue: 0.24) - private static let tokenColor = Color(red: 0.48, green: 0.41, blue: 0.86) - private static let requestColor = Color(red: 0.43, green: 0.73, blue: 0.62) - private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) - - private static func makeModel(snapshot: OpenAIAPIUsageSnapshot) -> Model { - let points = snapshot.daily.compactMap { day -> Point? in - guard let date = Self.dateFromDayKey(day.day) else { return nil } - return Point( - id: day.day, - day: day.day, - date: date, - costUSD: day.costUSD, - requests: day.requests, - totalTokens: day.totalTokens) - } - let pointsByDay = Dictionary(uniqueKeysWithValues: points.map { ($0.day, $0) }) - let dayDates = points.map { ($0.day, $0.date) } - let axisDates: [Date] = { - guard let first = points.first?.date, let last = points.last?.date else { return [] } - if Calendar.current.isDate(first, inSameDayAs: last) { return [first] } - return [first, last] - }() - let peak = points.max { lhs, rhs in - if lhs.costUSD == rhs.costUSD { return lhs.totalTokens < rhs.totalTokens } - return lhs.costUSD < rhs.costUSD - } - return Model( - points: points, - pointsByDay: pointsByDay, - dayDates: dayDates, - axisDates: axisDates, - peakSpendPoint: (peak?.costUSD ?? 0) > 0 ? peak : nil, - last30: snapshot.last30Days, - historyLabel: snapshot.historyWindowLabel) - } - - private func detail(model: Model) -> (primary: String, secondary: String?) { - let point = self.selectedDay.flatMap { model.pointsByDay[$0] } ?? model.points.last - guard let point else { return ("No selected day", nil) } - let primary = "\(Self.displayDate(point.date)): \(UsageFormatter.usdString(point.costUSD)) · " + - "\(UsageFormatter.tokenCountString(point.totalTokens)) tokens · " + - "\(UsageFormatter.tokenCountString(point.requests)) requests" - let bucket = self.snapshot.daily.first { $0.day == point.day } - let topModel = bucket?.models.first?.name - let topLineItem = bucket?.lineItems.first?.name - let secondary = [topModel.map { "Top model: \($0)" }, topLineItem.map { "Top spend: \($0)" }] - .compactMap(\.self) - .joined(separator: " · ") - return (primary, secondary.isEmpty ? nil : secondary) - } - - private func updateSelection(location: CGPoint?, model: Model, proxy: ChartProxy, geo: GeometryProxy) { - guard let location else { - if self.selectedDay != nil { self.selectedDay = nil } - return - } - guard !model.dayDates.isEmpty else { return } - guard let plotFrame = proxy.plotFrame else { return } - let frame = geo[plotFrame] - guard frame.contains(location) else { return } - let x = location.x - frame.origin.x - guard let date: Date = proxy.value(atX: x) else { return } - self.selectedDay = Self.nearestDay(to: date, in: model.dayDates) - } - - private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { - guard let selectedDay, let selected = model.dayDates.first(where: { $0.day == selectedDay }) else { - return nil - } - guard let plotFrame = proxy.plotFrame else { return nil } - let frame = geo[plotFrame] - guard let x = proxy.position(forX: selected.date) else { return nil } - let width = max(5, frame.width / CGFloat(max(model.dayDates.count, 1))) - return CGRect( - x: frame.origin.x + x - width / 2, - y: frame.origin.y, - width: width, - height: frame.height) - } - - private static func nearestDay(to date: Date, in days: [(day: String, date: Date)]) -> String? { - days.min { - abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) - }?.day - } - - private static func dateFromDayKey(_ key: String) -> Date? { - let parts = key.split(separator: "-") - guard parts.count == 3, - let year = Int(parts[0]), - let month = Int(parts[1]), - let day = Int(parts[2]) - else { return nil } - var comps = DateComponents() - comps.calendar = Calendar.current - comps.timeZone = TimeZone.current - comps.year = year - comps.month = month - comps.day = day - comps.hour = 12 - return comps.date - } - - private static func displayDate(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "MMM d" - return formatter.string(from: date) - } -} - -private struct StatPill: View { - let title: String - let value: String - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(self.title) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - .lineLimit(1) - Text(self.value) - .font(.caption) - .fontWeight(.semibold) - .lineLimit(1) - } - .padding(.vertical, 5) - .padding(.horizontal, 7) - .background(Color(nsColor: .separatorColor).opacity(0.35), in: RoundedRectangle(cornerRadius: 6)) - } -} - -private struct LegendRow: View { - let items: [(Color, String)] - - var body: some View { - HStack(spacing: 10) { - ForEach(self.items, id: \.1) { item in - HStack(spacing: 5) { - Circle() - .fill(item.0) - .frame(width: 7, height: 7) - Text(item.1) - .font(.caption2) - .foregroundStyle(.secondary) - } - } - Spacer(minLength: 0) - } - } -} diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 3556058b..ae995cc1 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -146,8 +146,11 @@ struct PlanUtilizationHistoryChartMenuView: View { } .chartLegend(.hidden) .frame(height: Layout.chartHeight) - .accessibilityLabel("Plan utilization chart") - .accessibilityValue(model.points.isEmpty ? "No data" : "\(model.points.count) utilization samples") + .accessibilityLabel(L("Plan utilization chart")) + .accessibilityValue( + model.points.isEmpty + ? L("No data") + : String(format: L("%d utilization samples"), model.points.count)) .chartOverlay { proxy in GeometryReader { geo in MouseLocationReader { location in @@ -608,9 +611,9 @@ struct PlanUtilizationHistoryChartMenuView: View { { switch name { case .session: - metadata?.sessionLabel ?? "Session" + L(metadata?.sessionLabel ?? "Session") case .weekly: - metadata?.weeklyLabel ?? "Weekly" + L(metadata?.weeklyLabel ?? "Weekly") case .opus: metadata?.opusLabel ?? "Opus" default: @@ -640,9 +643,9 @@ struct PlanUtilizationHistoryChartMenuView: View { private nonisolated static func emptyStateText(title: String?) -> String { if let title { - return "No \(title.lowercased()) utilization data yet." + return String(format: L("No %@ utilization data yet."), title.lowercased()) } - return "No utilization data yet." + return L("No utilization data yet.") } #if DEBUG @@ -707,7 +710,7 @@ struct PlanUtilizationHistoryChartMenuView: View { #endif private func xValue(for index: Int) -> PlottableValue { - .value("Series", Double(index)) + .value(L("Series"), Double(index)) } @ViewBuilder @@ -729,14 +732,14 @@ struct PlanUtilizationHistoryChartMenuView: View { ForEach(model.points) { point in BarMark( x: self.xValue(for: point.index), - yStart: .value("Capacity Start", 0), - yEnd: .value("Capacity End", 100), + yStart: .value(L("Capacity Start"), 0), + yEnd: .value(L("Capacity End"), 100), width: .fixed(Layout.barWidth)) .foregroundStyle(model.trackColor) BarMark( x: self.xValue(for: point.index), - yStart: .value("Utilization Start", 0), - yEnd: .value("Utilization End", point.usedPercent), + yStart: .value(L("Utilization Start"), 0), + yEnd: .value(L("Utilization End"), point.usedPercent), width: .fixed(Layout.barWidth)) .foregroundStyle(model.barColor) } @@ -809,16 +812,23 @@ extension PlanUtilizationHistoryChartMenuView { return "\(dateLabel): -" } let usedText = used.formatted(.number.precision(.fractionLength(0...1))) - return "\(dateLabel): \(usedText)% used" + return L("%@: %@%% used", dateLabel, usedText) } private nonisolated static func detailDateLabel(for date: Date, windowMinutes: Int) -> String { let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.locale = codexBarLocalizedLocale() formatter.timeZone = TimeZone.current - formatter.amSymbol = "am" - formatter.pmSymbol = "pm" - formatter.dateFormat = "MMM d, h:mm a" - return formatter.string(from: date) + formatter.setLocalizedDateFormatFromTemplate("MMM d, h:mm a") + var rendered = formatter.string(from: date).replacingOccurrences(of: "\u{202F}", with: " ") + let amSymbol = formatter.amSymbol ?? "" + let pmSymbol = formatter.pmSymbol ?? "" + if !amSymbol.isEmpty { + rendered = rendered.replacingOccurrences(of: amSymbol, with: amSymbol.lowercased()) + } + if !pmSymbol.isEmpty { + rendered = rendered.replacingOccurrences(of: pmSymbol, with: pmSymbol.lowercased()) + } + return rendered } } diff --git a/Sources/CodexBar/PreferencesCodexAccountsSection.swift b/Sources/CodexBar/PreferencesCodexAccountsSection.swift index c8d54143..f42be527 100644 --- a/Sources/CodexBar/PreferencesCodexAccountsSection.swift +++ b/Sources/CodexBar/PreferencesCodexAccountsSection.swift @@ -51,7 +51,7 @@ struct CodexAccountsSectionState: Equatable { } var systemDisplayName: String { - self.systemVisibleAccount?.displayName ?? "No system account" + self.systemVisibleAccount?.displayName ?? L("No system account") } var canAddAccount: Bool { @@ -64,9 +64,9 @@ struct CodexAccountsSectionState: Equatable { var addAccountTitle: String { if self.isAuthenticatingManagedAccount, self.authenticatingManagedAccountID == nil { - return "Adding Account…" + return L("Adding Account…") } - return "Add Account" + return L("Add Account") } func showsLiveBadge(for account: CodexVisibleAccount) -> Bool { @@ -113,12 +113,12 @@ struct CodexAccountsSectionState: Equatable { self.isAuthenticatingManagedAccount, self.authenticatingManagedAccountID == accountID { - return "Re-authenticating…" + return L("Re-authenticating…") } if account.storedAccountID == nil, self.isAuthenticatingLiveAccount { - return "Re-authenticating…" + return L("Re-authenticating…") } - return "Re-auth" + return L("Re-auth") } } @@ -132,11 +132,11 @@ struct CodexAccountsSectionView: View { let addAccount: () -> Void var body: some View { - ProviderSettingsSection(title: "Accounts") { + ProviderSettingsSection(title: L("Accounts")) { if let selection = self.activeSelectionBinding { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 10) { - Text("Active") + Text(L("Active")) .font(.subheadline.weight(.semibold)) .frame(width: ProviderSettingsMetrics.pickerLabelWidth, alignment: .leading) @@ -152,7 +152,7 @@ struct CodexAccountsSectionView: View { Spacer(minLength: 0) } - Text("Choose which Codex account CodexBar should follow.") + Text(L("Choose which Codex account CodexBar should follow.")) .font(.footnote) .foregroundStyle(.secondary) @@ -166,7 +166,7 @@ struct CodexAccountsSectionView: View { } else if let account = self.state.singleVisibleAccount { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 10) { - Text("Account") + Text(L("Account")) .font(.subheadline.weight(.semibold)) .frame(width: ProviderSettingsMetrics.pickerLabelWidth, alignment: .leading) @@ -181,7 +181,7 @@ struct CodexAccountsSectionView: View { } if self.state.visibleAccounts.isEmpty { - Text("No Codex accounts detected yet.") + Text(L("No Codex accounts detected yet.")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -235,7 +235,7 @@ struct CodexAccountsSectionView: View { @ViewBuilder private func systemRow(selection: Binding?) -> some View { HStack(alignment: .firstTextBaseline, spacing: 10) { - Text("System") + Text(L("System")) .font(.subheadline.weight(.semibold)) .frame(width: ProviderSettingsMetrics.pickerLabelWidth, alignment: .leading) @@ -273,7 +273,7 @@ struct CodexAccountsSectionView: View { Spacer(minLength: 0) } - Text("The default Codex account on this Mac.") + Text(L("The default Codex account on this Mac.")) .font(.footnote) .foregroundStyle(.secondary) } @@ -295,7 +295,7 @@ private struct CodexAccountsSectionRowView: View { Text(self.account.displayName) .font(.subheadline.weight(.semibold)) if self.showsSystemBadge { - Text("(System)") + Text(L("(System)")) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) } @@ -319,7 +319,7 @@ private struct CodexAccountsSectionRowView: View { } if self.account.canRemove { - Button("Remove") { + Button(L("Remove")) { self.onRemove() } .buttonStyle(.bordered) diff --git a/Sources/CodexBar/PreferencesDebugPane.swift b/Sources/CodexBar/PreferencesDebugPane.swift index b61160c5..89318aa1 100644 --- a/Sources/CodexBar/PreferencesDebugPane.swift +++ b/Sources/CodexBar/PreferencesDebugPane.swift @@ -48,7 +48,7 @@ struct DebugPane: View { .foregroundStyle(.tertiary) } Spacer() - Picker("Verbosity", selection: self.$settings.debugLogLevel) { + Picker(L("Verbosity"), selection: self.$settings.debugLogLevel) { ForEach(CodexBarLog.Level.allCases) { level in Text(level.displayName).tag(level) } @@ -77,7 +77,7 @@ struct DebugPane: View { title: L("section_loading_animations"), caption: L("loading_animations_caption")) { - Picker("Animation pattern", selection: self.animationPatternBinding) { + Picker(L("Animation pattern"), selection: self.animationPatternBinding) { Text(L("animation_random_default")).tag(nil as LoadingPattern?) ForEach(LoadingPattern.allCases) { pattern in Text(pattern.displayName).tag(Optional(pattern)) @@ -102,7 +102,7 @@ struct DebugPane: View { title: L("section_probe_logs"), caption: L("probe_logs_caption")) { - Picker("Provider", selection: self.$currentLogProvider) { + Picker(L("Provider"), selection: self.$currentLogProvider) { Text("Codex").tag(UsageProvider.codex) Text("Claude").tag(UsageProvider.claude) Text("Cursor").tag(UsageProvider.cursor) @@ -170,7 +170,7 @@ struct DebugPane: View { title: L("section_fetch_strategy"), caption: L("fetch_strategy_caption")) { - Picker("Provider", selection: self.$currentFetchProvider) { + Picker(L("Provider"), selection: self.$currentFetchProvider) { ForEach(UsageProvider.allCases, id: \.self) { provider in Text(provider.rawValue.capitalized).tag(provider) } @@ -261,7 +261,7 @@ struct DebugPane: View { title: L("section_notifications"), caption: L("notifications_caption")) { - Picker("Provider", selection: self.$currentLogProvider) { + Picker(L("Provider"), selection: self.$currentLogProvider) { Text("Codex").tag(UsageProvider.codex) Text("Claude").tag(UsageProvider.claude) } @@ -309,7 +309,7 @@ struct DebugPane: View { title: L("section_error_simulation"), caption: L("error_simulation_caption")) { - Picker("Provider", selection: self.$currentErrorProvider) { + Picker(L("Provider"), selection: self.$currentErrorProvider) { Text("Codex").tag(UsageProvider.codex) Text("Claude").tag(UsageProvider.claude) Text("Gemini").tag(UsageProvider.gemini) @@ -322,7 +322,7 @@ struct DebugPane: View { .pickerStyle(.segmented) .frame(width: 360) - TextField("Simulated error text", text: self.$simulatedErrorText, axis: .vertical) + TextField(L("Simulated error text"), text: self.$simulatedErrorText, axis: .vertical) .lineLimit(4) HStack(spacing: 12) { diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 4628a649..63de3853 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -50,7 +50,7 @@ struct DisplayPane: View { .foregroundStyle(.tertiary) } Spacer() - Picker("Display mode", selection: self.$settings.menuBarDisplayMode) { + Picker(L("Display mode"), selection: self.$settings.menuBarDisplayMode) { ForEach(MenuBarDisplayMode.allCases) { mode in Text(mode.label).tag(mode) } @@ -78,6 +78,25 @@ struct DisplayPane: View { title: L("show_quota_warning_markers_title"), subtitle: L("show_quota_warning_markers_subtitle"), binding: self.$settings.quotaWarningMarkersVisible) + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(L("weekly_progress_work_days_title")) + .font(.body) + Text(L("weekly_progress_work_days_subtitle")) + .font(.footnote) + .foregroundStyle(.tertiary) + } + Spacer() + Picker(L("weekly_progress_work_days_title"), selection: self.$settings.weeklyProgressWorkDays) { + Text(L("Off")).tag(nil as Int?) + Text(L("4 days")).tag(4 as Int?) + Text(L("5 days")).tag(5 as Int?) + Text(L("7 days")).tag(7 as Int?) + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 100) + } PreferenceToggleRow( title: L("show_reset_time_as_clock_title"), subtitle: L("show_reset_time_as_clock_subtitle"), diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 58a6afc7..181b4963 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -8,7 +8,9 @@ enum AppLanguage: String, CaseIterable, Identifiable { case spanish = "es" case catalan = "ca" case chineseSimplified = "zh-Hans" + case chineseTraditional = "zh-Hant" case portugueseBrazilian = "pt-BR" + case swedish = "sv" var id: String { self.rawValue @@ -21,7 +23,9 @@ enum AppLanguage: String, CaseIterable, Identifiable { case .spanish: L("language_spanish") case .catalan: L("language_catalan") case .chineseSimplified: L("language_chinese_simplified") + case .chineseTraditional: L("language_chinese_traditional") case .portugueseBrazilian: L("language_portuguese_brazilian") + case .swedish: L("language_swedish") } } } @@ -129,7 +133,7 @@ struct GeneralPane: View { .foregroundStyle(.tertiary) } Spacer() - Picker("Refresh cadence", selection: self.$settings.refreshFrequency) { + Picker(L("Refresh cadence"), selection: self.$settings.refreshFrequency) { ForEach(RefreshFrequency.allCases) { option in Text(option.label).tag(option) } @@ -202,8 +206,9 @@ struct GeneralPane: View { } if let snapshot = self.store.tokenSnapshot(for: provider) { let updated = UsageFormatter.updatedString(from: snapshot.updatedAt) - let cost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" - let window = snapshot.historyDays == 1 ? "today" : "\(snapshot.historyDays)d" + let cost = snapshot.last30DaysCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let window = snapshot.historyLabel ?? (snapshot.historyDays == 1 ? "today" : "\(snapshot.historyDays)d") return Text(String(format: L("cost_status_snapshot"), name, updated, window, cost)) .font(.footnote) .foregroundStyle(.tertiary) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index d6a850c9..36dd0ce6 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -237,7 +237,7 @@ private struct ProviderDetailHeaderView: View { } .buttonStyle(.bordered) .controlSize(.small) - .help("Refresh") + .help(L("Refresh")) Toggle("", isOn: self.$isEnabled) .labelsHidden() @@ -575,7 +575,7 @@ private struct ProviderMetricInlineCostRow: View { UsageProgressBar( percent: percentUsed, tint: self.progressColor, - accessibilityLabel: "Usage used") + accessibilityLabel: L("Usage used")) .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) } diff --git a/Sources/CodexBar/PreferencesProviderErrorView.swift b/Sources/CodexBar/PreferencesProviderErrorView.swift index 4b5c96b7..156dc627 100644 --- a/Sources/CodexBar/PreferencesProviderErrorView.swift +++ b/Sources/CodexBar/PreferencesProviderErrorView.swift @@ -26,7 +26,7 @@ struct ProviderErrorView: View { } .buttonStyle(.plain) .foregroundStyle(.secondary) - .help("Copy error") + .help(L("Copy error")) } Text(self.display.preview) diff --git a/Sources/CodexBar/PreferencesProviderSettingsRows.swift b/Sources/CodexBar/PreferencesProviderSettingsRows.swift index c4f4cb81..5d19dc14 100644 --- a/Sources/CodexBar/PreferencesProviderSettingsRows.swift +++ b/Sources/CodexBar/PreferencesProviderSettingsRows.swift @@ -23,7 +23,7 @@ struct ProviderSettingsSection: View { var body: some View { VStack(alignment: .leading, spacing: self.spacing) { - Text(self.title) + Text(L(self.title)) .font(.headline) self.content() } @@ -41,9 +41,9 @@ struct ProviderSettingsToggleRowView: View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text(self.toggle.title) + Text(L(self.toggle.title)) .font(.subheadline.weight(.semibold)) - Text(self.toggle.subtitle) + Text(L(self.toggle.subtitle)) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -67,7 +67,7 @@ struct ProviderSettingsToggleRowView: View { if !actions.isEmpty { HStack(spacing: 10) { ForEach(actions) { action in - Button(action.title) { + Button(L(action.title)) { Task { @MainActor in await action.perform() } @@ -101,13 +101,13 @@ struct ProviderSettingsPickerRowView: View { let isEnabled = self.picker.isEnabled?() ?? true VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 10) { - Text(self.picker.title) + Text(L(self.picker.title)) .font(.subheadline.weight(.semibold)) .frame(width: ProviderSettingsMetrics.pickerLabelWidth, alignment: .leading) Picker("", selection: self.picker.binding) { ForEach(self.picker.options) { option in - Text(option.title).tag(option.id) + Text(L(option.title)).tag(option.id) } } .labelsHidden() @@ -128,7 +128,7 @@ struct ProviderSettingsPickerRowView: View { let subtitle = self.picker.dynamicSubtitle?() ?? self.picker.subtitle if !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text(subtitle) + Text(L(subtitle)) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -157,11 +157,11 @@ struct ProviderSettingsFieldRowView: View { if hasHeader { VStack(alignment: .leading, spacing: 4) { if !trimmedTitle.isEmpty { - Text(trimmedTitle) + Text(L(trimmedTitle)) .font(.subheadline.weight(.semibold)) } if !trimmedSubtitle.isEmpty { - Text(trimmedSubtitle) + Text(L(trimmedSubtitle)) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -171,12 +171,12 @@ struct ProviderSettingsFieldRowView: View { switch self.field.kind { case .plain: - TextField(self.field.placeholder ?? "", text: self.field.binding) + TextField(L(self.field.placeholder ?? ""), text: self.field.binding) .textFieldStyle(.roundedBorder) .font(.footnote) .onTapGesture { self.field.onActivate?() } case .secure: - SecureField(self.field.placeholder ?? "", text: self.field.binding) + SecureField(L(self.field.placeholder ?? ""), text: self.field.binding) .textFieldStyle(.roundedBorder) .font(.footnote) .onTapGesture { self.field.onActivate?() } @@ -186,7 +186,7 @@ struct ProviderSettingsFieldRowView: View { if !actions.isEmpty { HStack(spacing: 10) { ForEach(actions) { action in - Button(action.title) { + Button(L(action.title)) { Task { @MainActor in await action.perform() } @@ -198,7 +198,7 @@ struct ProviderSettingsFieldRowView: View { } if let footer = self.field.footerText, !footer.isEmpty { - Text(footer) + Text(L(footer)) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -213,11 +213,11 @@ struct ProviderSettingsActionsRowView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Text(self.descriptor.title) + Text(L(self.descriptor.title)) .font(.subheadline.weight(.semibold)) if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text(self.descriptor.subtitle) + Text(L(self.descriptor.subtitle)) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -227,7 +227,7 @@ struct ProviderSettingsActionsRowView: View { if !actions.isEmpty { HStack(spacing: 10) { ForEach(actions) { action in - Button(action.title) { + Button(L(action.title)) { Task { @MainActor in await action.perform() } @@ -251,13 +251,13 @@ struct ProviderSettingsTokenAccountsRowView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .center, spacing: 12) { - Text(self.descriptor.title) + Text(L(self.descriptor.title)) .font(.subheadline.weight(.semibold)) Spacer(minLength: 8) if let title = self.descriptor.primaryAddActionTitle, let action = self.descriptor.primaryAddAction { - Button(title) { + Button(L(title)) { Task { @MainActor in await action() } @@ -268,7 +268,7 @@ struct ProviderSettingsTokenAccountsRowView: View { } if !self.descriptor.subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text(self.descriptor.subtitle) + Text(L(self.descriptor.subtitle)) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -276,7 +276,7 @@ struct ProviderSettingsTokenAccountsRowView: View { let accounts = self.descriptor.accounts() if accounts.isEmpty { - Text("No token accounts yet.") + Text(L("No token accounts yet.")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -304,7 +304,7 @@ struct ProviderSettingsTokenAccountsRowView: View { } .buttonStyle(.plain) - Button("Remove") { + Button(L("Remove")) { self.descriptor.removeAccount(account.id) } .buttonStyle(.bordered) @@ -320,13 +320,13 @@ struct ProviderSettingsTokenAccountsRowView: View { if self.descriptor.primaryAddAction == nil { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { - TextField("Label", text: self.$newLabel) + TextField(L("Label"), text: self.$newLabel) .textFieldStyle(.roundedBorder) .font(.footnote) - SecureField(self.descriptor.placeholder, text: self.$newToken) + SecureField(L(self.descriptor.placeholder), text: self.$newToken) .textFieldStyle(.roundedBorder) .font(.footnote) - Button("Add") { + Button(L("Add")) { let label = self.newLabel.trimmingCharacters(in: .whitespacesAndNewlines) let token = self.newToken.trimmingCharacters(in: .whitespacesAndNewlines) guard !label.isEmpty, !token.isEmpty else { return } @@ -344,21 +344,22 @@ struct ProviderSettingsTokenAccountsRowView: View { self.newToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } if self.descriptor.showsOrganizationField { - TextField("Org ID (optional)", text: self.$newOrgID) + TextField(L("Org ID (optional)"), text: self.$newOrgID) .textFieldStyle(.roundedBorder) .font(.footnote) - .help("Optional organization ID for accounts linked to multiple Anthropic organizations.") + .help( + L("Optional organization ID for accounts linked to multiple Anthropic organizations.")) } } } HStack(spacing: 10) { - Button("Open token file") { + Button(L("Open token file")) { self.descriptor.openConfigFile() } .buttonStyle(.link) .controlSize(.small) - Button("Reload") { + Button(L("Reload")) { self.descriptor.reloadFromDisk() } .buttonStyle(.link) @@ -395,7 +396,7 @@ struct ProviderSettingsOrganizationsRowView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .center, spacing: 12) { - Text(self.descriptor.title) + Text(L(self.descriptor.title)) .font(.subheadline.weight(.semibold)) Spacer(minLength: 8) } @@ -403,7 +404,7 @@ struct ProviderSettingsOrganizationsRowView: View { if let subtitle = self.descriptor.subtitle, !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text(subtitle) + Text(L(subtitle)) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -411,7 +412,7 @@ struct ProviderSettingsOrganizationsRowView: View { let entries = self.descriptor.entries() if entries.allSatisfy(\.isLocked) { - Text("No organizations loaded. Click Refresh after setting your API key.") + Text(L("No organizations loaded. Click Refresh after setting your API key.")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -423,12 +424,12 @@ struct ProviderSettingsOrganizationsRowView: View { self.descriptor.onToggle(entry.id, newValue) })) { VStack(alignment: .leading, spacing: 1) { - Text(entry.title) + Text(entry.localizesTitle ? L(entry.title) : entry.title) .font(.footnote) if let subtitle = entry.subtitle, !subtitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text(subtitle) + Text(entry.localizesSubtitle ? L(subtitle) : subtitle) .font(.caption) .foregroundStyle(.secondary) } @@ -441,7 +442,7 @@ struct ProviderSettingsOrganizationsRowView: View { } HStack(spacing: 10) { - Button("Refresh organizations") { + Button(L("Refresh organizations")) { Task { @MainActor in self.isRefreshing = true let result = await self.descriptor.onRefresh() diff --git a/Sources/CodexBar/PreferencesProviderSidebarView.swift b/Sources/CodexBar/PreferencesProviderSidebarView.swift index 559c5329..59e21ffb 100644 --- a/Sources/CodexBar/PreferencesProviderSidebarView.swift +++ b/Sources/CodexBar/PreferencesProviderSidebarView.swift @@ -5,43 +5,58 @@ import UniformTypeIdentifiers @MainActor struct ProviderSidebarListView: View { let providers: [UsageProvider] + let orderedProviders: [UsageProvider] @Bindable var store: UsageStore let isEnabled: (UsageProvider) -> Binding let subtitle: (UsageProvider) -> String + @Binding var searchText: String @Binding var selection: UsageProvider? let moveProviders: (IndexSet, Int) -> Void @State private var draggingProvider: UsageProvider? var body: some View { - ScrollView { - VStack(spacing: 0) { - ForEach(self.providers, id: \.self) { provider in - ProviderSidebarRowView( - provider: provider, - store: self.store, - isEnabled: self.isEnabled(provider), - subtitle: self.subtitle(provider), - draggingProvider: self.$draggingProvider) - .padding(.horizontal, 8) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill( - self.selection == provider - ? Color(nsColor: .selectedContentBackgroundColor) - : Color.clear) - .padding(.horizontal, 4)) - .contentShape(Rectangle()) - .onTapGesture { self.selection = provider } - .onDrop( - of: [UTType.plainText], - delegate: ProviderSidebarDropDelegate( - item: provider, - providers: self.providers, - dragging: self.$draggingProvider, - moveProviders: self.moveProviders)) + VStack(spacing: 8) { + ProviderSidebarSearchField(searchText: self.$searchText) + .padding(.horizontal, 8) + .padding(.top, 8) + + ScrollView { + VStack(spacing: 0) { + if self.providers.isEmpty { + Text(L("No matching providers")) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 80) + } + + ForEach(self.providers, id: \.self) { provider in + ProviderSidebarRowView( + provider: provider, + store: self.store, + isEnabled: self.isEnabled(provider), + subtitle: self.subtitle(provider), + draggingProvider: self.$draggingProvider) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + self.selection == provider + ? Color(nsColor: .selectedContentBackgroundColor) + : Color.clear) + .padding(.horizontal, 4)) + .contentShape(Rectangle()) + .onTapGesture { self.selection = provider } + .onDrop( + of: [UTType.plainText], + delegate: ProviderSidebarDropDelegate( + item: provider, + providers: self.orderedProviders, + dragging: self.$draggingProvider, + moveProviders: self.moveProviders)) + } } + .padding(.vertical, 4) } - .padding(.vertical, 4) } .background( RoundedRectangle(cornerRadius: ProviderSettingsMetrics.sidebarCornerRadius, style: .continuous) @@ -54,6 +69,41 @@ struct ProviderSidebarListView: View { } } +private struct ProviderSidebarSearchField: View { + @Binding var searchText: String + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + .accessibilityHidden(true) + + TextField(L("Search providers"), text: self.$searchText) + .textFieldStyle(.plain) + + if !self.searchText.isEmpty { + Button { + self.searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + .accessibilityLabel(L("Clear")) + } + .buttonStyle(.plain) + } + } + .font(.callout) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor))) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor).opacity(0.7), lineWidth: 1)) + } +} + @MainActor private struct ProviderSidebarRowView: View { let provider: UsageProvider @@ -72,7 +122,7 @@ private struct ProviderSidebarRowView: View { .contentShape(Rectangle()) .padding(.vertical, 4) .padding(.horizontal, 2) - .help("Drag to reorder") + .help(L("Drag to reorder")) .onDrag { self.draggingProvider = self.provider return NSItemProvider(object: self.provider.rawValue as NSString) @@ -109,6 +159,7 @@ private struct ProviderSidebarRowView: View { .toggleStyle(.checkbox) .controlSize(.small) } + .padding(.trailing, 6) .contentShape(Rectangle()) .padding(.vertical, 2) } @@ -119,9 +170,9 @@ private struct ProviderSidebarRowView: View { if lines.count >= 2 { let first = lines[0] let rest = lines.dropFirst().joined(separator: "\n") - return "Disabled — \(first)\n\(rest)" + return "\(L("Disabled")) — \(first)\n\(rest)" } - return "Disabled — \(self.subtitle)" + return "\(L("Disabled")) — \(self.subtitle)" } } @@ -145,7 +196,7 @@ private struct ProviderSidebarReorderHandle: View { width: ProviderSettingsMetrics.reorderHandleSize, height: ProviderSettingsMetrics.reorderHandleSize) .foregroundStyle(.tertiary) - .accessibilityLabel("Reorder") + .accessibilityLabel(L("Reorder")) } } diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index d7f82c23..55d11441 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -16,12 +16,20 @@ struct ProvidersPane: View { @State private var activeConfirmation: ProviderSettingsConfirmationState? @State private var codexAccountsNotice: CodexAccountsSectionNotice? @State private var isAuthenticatingLiveCodexAccount = false + @State private var providerSearchText = "" @State private var selectedProvider: UsageProvider? private var providers: [UsageProvider] { self.settings.orderedProviders() } + private var filteredProviders: [UsageProvider] { + Self.filteredProviders( + self.providers, + query: self.providerSearchText, + displayName: { provider in self.store.metadata(for: provider).displayName }) + } + init( settings: SettingsStore, store: UsageStore, @@ -45,16 +53,18 @@ struct ProvidersPane: View { var body: some View { HStack(alignment: .top, spacing: 16) { ProviderSidebarListView( - providers: self.providers, + providers: self.filteredProviders, + orderedProviders: self.providers, store: self.store, isEnabled: { provider in self.binding(for: provider) }, subtitle: { provider in self.providerSubtitle(provider) }, + searchText: self.$providerSearchText, selection: self.$selectedProvider, moveProviders: { fromOffsets, toOffset in self.settings.moveProvider(fromOffsets: fromOffsets, toOffset: toOffset) }) - if let provider = self.selectedProvider ?? self.providers.first { + if let provider = self.selectedVisibleProvider { ProviderDetailView( provider: provider, store: self.store, @@ -116,6 +126,9 @@ struct ProvidersPane: View { .onChange(of: self.providers) { _, _ in self.ensureSelection() } + .onChange(of: self.providerSearchText) { _, _ in + self.ensureSelection() + } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in self.runSettingsDidBecomeActiveHooks() } @@ -142,15 +155,38 @@ struct ProvidersPane: View { }) } + private var selectedVisibleProvider: UsageProvider? { + let filteredProviders = self.filteredProviders + if let selected = self.selectedProvider, filteredProviders.contains(selected) { + return selected + } + return filteredProviders.first + } + + static func filteredProviders( + _ providers: [UsageProvider], + query: String, + displayName: (UsageProvider) -> String) -> [UsageProvider] + { + let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedQuery.isEmpty else { return providers } + + return providers.filter { provider in + displayName(provider).localizedCaseInsensitiveContains(trimmedQuery) + || provider.rawValue.localizedCaseInsensitiveContains(trimmedQuery) + } + } + private func ensureSelection() { - guard !self.providers.isEmpty else { + let filteredProviders = self.filteredProviders + guard !filteredProviders.isEmpty else { self.selectedProvider = nil return } - if let selected = self.selectedProvider, self.providers.contains(selected) { + if let selected = self.selectedProvider, filteredProviders.contains(selected) { return } - self.selectedProvider = self.providers.first + self.selectedProvider = filteredProviders.first } private func triggerRefresh(for provider: UsageProvider) { @@ -488,7 +524,7 @@ struct ProvidersPane: View { ] } else if SettingsStore.isBalanceOnlyProvider(provider) { options = [ - ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: "Automatic"), + ProviderSettingsPickerOption(id: MenuBarMetricPreference.automatic.rawValue, title: L("Automatic")), ] } else if provider == .abacus { let metadata = self.store.metadata(for: provider) @@ -586,7 +622,7 @@ struct ProvidersPane: View { dashboardError = codexProjection.userFacingErrors.dashboard tokenSnapshot = self.store.tokenSnapshot(for: provider) tokenError = self.store.tokenError(for: provider) - } else if provider == .claude || provider == .vertexai { + } else if ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost { credits = nil creditsError = nil dashboard = nil @@ -637,6 +673,7 @@ struct ProvidersPane: View { .session: self.quotaWarningMarkerThresholds(provider: provider, window: .session), .weekly: self.quotaWarningMarkerThresholds(provider: provider, window: .weekly), ], + workDaysPerWeek: self.settings.weeklyProgressWorkDays, now: now) return UsageMenuCardView.Model.make(input) } @@ -667,17 +704,7 @@ struct ProvidersPane: View { } if let error = error as? ManagedCodexAccountServiceError { - let message = switch error { - case .loginFailed: - L("managed_login_failed") - case .missingEmail: - L("managed_login_missing_email") - case .workspaceSelectionCancelled: - L("workspace_selection_cancelled") - case let .unsafeManagedHome(path): - String(format: L("unsafe_managed_home"), path) - } - return CodexAccountsSectionNotice(text: message, tone: .warning) + return CodexAccountsSectionNotice(text: error.userFacingMessage, tone: .warning) } return CodexAccountsSectionNotice( @@ -687,8 +714,8 @@ struct ProvidersPane: View { private func presentLoginAlert(title: String, message: String) { let alert = NSAlert() - alert.messageText = title - alert.informativeText = message + alert.messageText = L(title) + alert.informativeText = L(message) alert.alertStyle = .warning alert.runModal() } @@ -753,9 +780,9 @@ struct ProviderSettingsConfirmationState: Identifiable { } init(confirmation: ProviderSettingsConfirmation) { - self.title = confirmation.title - self.message = confirmation.message - self.confirmTitle = confirmation.confirmTitle + self.title = L(confirmation.title) + self.message = L(confirmation.message) + self.confirmTitle = L(confirmation.confirmTitle) self.onConfirm = confirmation.onConfirm } } diff --git a/Sources/CodexBar/ProviderBrandIcon.swift b/Sources/CodexBar/ProviderBrandIcon.swift index cec160ad..86fc8d7b 100644 --- a/Sources/CodexBar/ProviderBrandIcon.swift +++ b/Sources/CodexBar/ProviderBrandIcon.swift @@ -6,6 +6,9 @@ enum ProviderBrandIcon { /// Lazy-loaded resource bundle for provider icons. private static let resourceBundle: Bundle? = { + guard Bundle.main.bundleURL.pathExtension == "app" else { + return Bundle.module + } // SwiftPM creates a CodexBar_CodexBar.bundle for resources in the CodexBar target. if let bundleURL = Bundle.main.url(forResource: "CodexBar_CodexBar", withExtension: "bundle"), let bundle = Bundle(url: bundleURL) diff --git a/Sources/CodexBar/ProviderRegistry.swift b/Sources/CodexBar/ProviderRegistry.swift index b38f9875..eeeb541b 100644 --- a/Sources/CodexBar/ProviderRegistry.swift +++ b/Sources/CodexBar/ProviderRegistry.swift @@ -74,6 +74,13 @@ struct ProviderRegistry { token: token) } }, + providerManualTokenUpdater: { provider, token in + await MainActor.run { + if provider == .stepfun { + settings.stepfunToken = token + } + } + }, costUsageHistoryDays: settings.costUsageHistoryDays) }) specs[provider] = spec diff --git a/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift b/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift index c1529bd5..bad4a58a 100644 --- a/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Augment/AugmentProviderImplementation.swift @@ -83,14 +83,14 @@ struct AugmentProviderImplementation: ProviderImplementation { @MainActor func appendActionMenuEntries(context: ProviderMenuActionContext, entries: inout [ProviderMenuEntry]) { - entries.append(.action("Refresh Session", .refreshAugmentSession)) + entries.append(.action(L("Refresh Session"), .refreshAugmentSession)) if let error = context.store.error(for: .augment) { if error.contains("session has expired") || error.contains("No Augment session cookie found") { entries.append(.action( - "Open Augment (Log Out & Back In)", + L("Open Augment (Log Out & Back In)"), .loginToProvider(url: "https://app.augmentcode.com"))) } } diff --git a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift index b2593c54..0a6bb90d 100644 --- a/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift +++ b/Sources/CodexBar/Providers/Augment/AugmentProviderRuntime.swift @@ -92,6 +92,7 @@ final class AugmentProviderRuntime: ProviderRuntime { private func forceRefresh(context: ProviderRuntimeContext) async { #if os(macOS) context.store.augmentLogger.info("Augment force refresh requested") + CookieHeaderCache.clear(provider: .augment) guard let keepalive = self.keepalive else { context.store.augmentLogger.warning("Augment keepalive not running; starting") self.startKeepalive(context: context) @@ -105,8 +106,6 @@ final class AugmentProviderRuntime: ProviderRuntime { } await keepalive.forceRefresh() - context.store.augmentLogger.info("Refreshing Augment usage after session refresh") - await context.store.refreshProvider(.augment) #endif } } diff --git a/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift b/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift index 9de35370..ecca9e11 100644 --- a/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Bedrock/BedrockProviderImplementation.swift @@ -15,6 +15,8 @@ struct BedrockProviderImplementation: ProviderImplementation { @MainActor func observeSettings(_ settings: SettingsStore) { + _ = settings.bedrockAuthMode + _ = settings.bedrockProfile _ = settings.bedrockAccessKeyID _ = settings.bedrockSecretAccessKey _ = settings.bedrockRegion @@ -25,9 +27,43 @@ struct BedrockProviderImplementation: ProviderImplementation { BedrockSettingsReader.hasCredentials(environment: context.environment) } + @MainActor + func settingsPickers(context: ProviderSettingsContext) -> [ProviderSettingsPickerDescriptor] { + let binding = Binding( + get: { context.settings.bedrockAuthMode }, + set: { context.settings.bedrockAuthMode = $0 }) + let options = [ + ProviderSettingsPickerOption(id: BedrockAuthMode.keys.rawValue, title: "Access keys"), + ProviderSettingsPickerOption(id: BedrockAuthMode.profile.rawValue, title: "AWS profile"), + ] + return [ + ProviderSettingsPickerDescriptor( + id: "bedrock-auth-mode", + title: "Authentication", + subtitle: "Use static access keys, or resolve credentials from a named AWS profile " + + "(supports SSO and assume-role via the AWS CLI).", + binding: binding, + options: options, + isVisible: nil, + onChange: nil), + ] + } + @MainActor func settingsFields(context: ProviderSettingsContext) -> [ProviderSettingsFieldDescriptor] { - [ + let isKeysMode = { context.settings.bedrockAuthMode != BedrockAuthMode.profile.rawValue } + let isProfileMode = { context.settings.bedrockAuthMode == BedrockAuthMode.profile.rawValue } + return [ + ProviderSettingsFieldDescriptor( + id: "bedrock-profile", + title: "Profile name", + subtitle: "Named AWS profile from ~/.aws/config. Can also be set with AWS_PROFILE.", + kind: .plain, + placeholder: "default", + binding: context.stringBinding(\.bedrockProfile), + actions: [], + isVisible: isProfileMode, + onActivate: nil), ProviderSettingsFieldDescriptor( id: "bedrock-access-key-id", title: "Access key ID", @@ -36,7 +72,7 @@ struct BedrockProviderImplementation: ProviderImplementation { placeholder: "AKIA...", binding: context.stringBinding(\.bedrockAccessKeyID), actions: [], - isVisible: nil, + isVisible: isKeysMode, onActivate: nil), ProviderSettingsFieldDescriptor( id: "bedrock-secret-access-key", @@ -46,12 +82,13 @@ struct BedrockProviderImplementation: ProviderImplementation { placeholder: "", binding: context.stringBinding(\.bedrockSecretAccessKey), actions: [], - isVisible: nil, + isVisible: isKeysMode, onActivate: nil), ProviderSettingsFieldDescriptor( id: "bedrock-region", title: "Region", - subtitle: "AWS region. Can also be set with AWS_REGION.", + subtitle: "AWS region. Can also be set with AWS_REGION. " + + "In profile mode, leave blank to use the profile's region.", kind: .plain, placeholder: "us-east-1", binding: context.stringBinding(\.bedrockRegion), diff --git a/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift b/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift index 9d51fc99..f01a5706 100644 --- a/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift +++ b/Sources/CodexBar/Providers/Bedrock/BedrockSettingsStore.swift @@ -31,4 +31,28 @@ extension SettingsStore { self.logProviderModeChange(provider: .bedrock, field: "region", value: newValue) } } + + var bedrockAuthMode: String { + get { + self.configSnapshot.providerConfig(for: .bedrock)?.sanitizedAWSAuthMode + ?? BedrockAuthMode.keys.rawValue + } + set { + let normalized = BedrockAuthMode(rawValue: newValue)?.rawValue ?? BedrockAuthMode.keys.rawValue + self.updateProviderConfig(provider: .bedrock) { entry in + entry.awsAuthMode = normalized + } + self.logProviderModeChange(provider: .bedrock, field: "authMode", value: normalized) + } + } + + var bedrockProfile: String { + get { self.configSnapshot.providerConfig(for: .bedrock)?.awsProfile ?? "" } + set { + self.updateProviderConfig(provider: .bedrock) { entry in + entry.awsProfile = self.normalizedConfigValue(newValue) + } + self.logProviderModeChange(provider: .bedrock, field: "profile", value: newValue) + } + } } diff --git a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift index 0d521587..287db61f 100644 --- a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift +++ b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift @@ -2,6 +2,9 @@ import CodexBarCore import Foundation struct CodexUIErrorMapper { + private static let codexCLINotSignedInMessage = + "Codex CLI is not signed in. Run `codex login --device-auth`, then refresh." + static func userFacingMessage(_ raw: String?) -> String? { guard let raw, !raw.isEmpty else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) @@ -20,6 +23,10 @@ struct CodexUIErrorMapper { return CodexStatusProbeError.codexNotInstalled.localizedDescription } + if self.looksCodexCLILoginRequired(lower: lower) { + return self.codexCLINotSignedInMessage + } + if self.looksExpired(lower: lower) { return "Codex session expired. Sign in again." } @@ -72,6 +79,7 @@ struct CodexUIErrorMapper { || lower.contains("selected managed codex account is unavailable") || lower.contains("codex credits are still loading") || lower.contains("codex account changed; importing browser cookies") + || lower.contains("codex cli is not signed in.") || lower.contains("codex session expired. sign in again.") || lower.contains("openai web refresh timed out. refresh openai cookies and try again.") || lower.contains( @@ -89,6 +97,12 @@ struct CodexUIErrorMapper { || (lower.contains("binary not found") && lower.contains("codex")) } + private static func looksCodexCLILoginRequired(lower: String) -> Bool { + lower.contains("codex account authentication required") + || lower.contains("account authentication required to read rate limits") + || lower.contains("requiresopenaiauth") + } + private static func looksExpired(lower: String) -> Bool { lower.contains("token_expired") || lower.contains("authentication token is expired") diff --git a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift index 8bfb331f..592da489 100644 --- a/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift +++ b/Sources/CodexBar/Providers/Codex/CodexSettingsStore.swift @@ -128,6 +128,7 @@ extension SettingsStore { self.codexPersistedActiveSource } set { + self.invalidateCodexAccountReconciliationSnapshotCache() self.updateProviderConfig(provider: .codex) { entry in entry.codexActiveSource = newValue } @@ -150,6 +151,12 @@ extension SettingsStore { return true } + @discardableResult + func refreshCodexAccountReconciliationAfterManagedAccountsDidChange() -> Bool { + self.invalidateCodexAccountReconciliationSnapshotCache() + return self.persistResolvedCodexActiveSourceCorrectionIfNeeded() + } + var codexCookieHeader: String { get { self.configSnapshot.providerConfig(for: .codex)?.sanitizedCookieHeader ?? "" } set { @@ -180,6 +187,19 @@ extension SettingsStore { } extension SettingsStore { + private static var codexAccountReconciliationSnapshotCacheInterval: TimeInterval { + #if DEBUG + if let codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting { + return codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting + } + #endif + return self.isRunningTests ? 0 : self.productionCodexAccountReconciliationSnapshotCacheInterval + } + + func invalidateCodexAccountReconciliationSnapshotCache() { + self.cachedCodexAccountReconciliationSnapshot = nil + } + var codexAccountReconciliationSnapshot: CodexAccountReconciliationSnapshot { self.codexAccountReconciliationSnapshot(activeSourceOverride: nil) } @@ -187,9 +207,25 @@ extension SettingsStore { func codexAccountReconciliationSnapshot( activeSourceOverride: CodexActiveSource?) -> CodexAccountReconciliationSnapshot { - self.codexAccountReconciler( - activeSource: activeSourceOverride ?? self.codexPersistedActiveSource) - .loadSnapshot() + let activeSource = activeSourceOverride ?? self.codexPersistedActiveSource + let cacheInterval = Self.codexAccountReconciliationSnapshotCacheInterval + let now = Date() + if cacheInterval > 0, + let cached = self.cachedCodexAccountReconciliationSnapshot, + cached.activeSource == activeSource, + now.timeIntervalSince(cached.loadedAt) < cacheInterval + { + return cached.snapshot + } + + let snapshot = self.codexAccountReconciler(activeSource: activeSource).loadSnapshot() + if cacheInterval > 0 { + self.cachedCodexAccountReconciliationSnapshot = CachedCodexAccountReconciliationSnapshot( + activeSource: activeSource, + loadedAt: now, + snapshot: snapshot) + } + return snapshot } var codexVisibleAccountProjection: CodexVisibleAccountProjection { @@ -203,6 +239,7 @@ extension SettingsStore { @discardableResult func selectCodexVisibleAccount(id: String) -> Bool { guard let source = self.codexSource(forVisibleAccountID: id) else { return false } + self.invalidateCodexAccountReconciliationSnapshotCache() self.codexActiveSource = source return true } @@ -212,6 +249,7 @@ extension SettingsStore { return } // An open menu can preserve a previously rendered account row while the live projection is briefly incomplete. + self.invalidateCodexAccountReconciliationSnapshotCache() self.codexActiveSource = account.selectionSource } @@ -224,6 +262,7 @@ extension SettingsStore { return } + self.invalidateCodexAccountReconciliationSnapshotCache() self.codexActiveSource = .managedAccount(id: account.id) _ = self.persistResolvedCodexActiveSourceCorrectionIfNeeded() } @@ -469,32 +508,50 @@ private struct CodexManagedRemoteHomeTestingSystemObserver: CodexSystemAccountOb extension SettingsStore { var _test_activeManagedCodexRemoteHomePath: String? { get { CodexManagedRemoteHomeTestingOverride.homePath(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setHomePath(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setHomePath(newValue, for: self) + } } var _test_activeManagedCodexAccount: ManagedCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.account(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setAccount(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setAccount(newValue, for: self) + } } var _test_unreadableManagedCodexAccountStore: Bool { get { CodexManagedRemoteHomeTestingOverride.isUnreadable(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setUnreadable(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setUnreadable(newValue, for: self) + } } var _test_managedCodexAccountStoreURL: URL? { get { CodexManagedRemoteHomeTestingOverride.managedStoreURL(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setManagedStoreURL(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setManagedStoreURL(newValue, for: self) + } } var _test_liveSystemCodexAccount: ObservedSystemCodexAccount? { get { CodexManagedRemoteHomeTestingOverride.liveSystemAccount(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setLiveSystemAccount(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setLiveSystemAccount(newValue, for: self) + } } var _test_codexReconciliationEnvironment: [String: String]? { get { CodexManagedRemoteHomeTestingOverride.reconciliationEnvironment(for: self) } - set { CodexManagedRemoteHomeTestingOverride.setReconciliationEnvironment(newValue, for: self) } + set { + self.invalidateCodexAccountReconciliationSnapshotCache() + CodexManagedRemoteHomeTestingOverride.setReconciliationEnvironment(newValue, for: self) + } } } #endif diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift index 192f7f34..dc060801 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift @@ -13,6 +13,67 @@ extension UsageStore { self.makeFetchContext(provider: .codex, override: nil).fetcher } + func scheduleCreditsRefreshIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) { + let refreshKey = self.codexCreditsRefreshKey( + expectedGuard: self.currentCodexAccountScopedRefreshGuard()) + if let existing = self.creditsRefreshTask, + !existing.isCancelled, + self.creditsRefreshTaskKey == refreshKey + { + return + } + + self.creditsRefreshTask?.cancel() + self.creditsRefreshTaskKey = refreshKey + self.creditsRefreshTask = Task(priority: .utility) { @MainActor [weak self] in + guard let self else { return } + defer { + if self.creditsRefreshTaskKey == refreshKey { + self.creditsRefreshTask = nil + self.creditsRefreshTaskKey = nil + } + } + await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: minimumSnapshotUpdatedAt) + guard !Task.isCancelled else { return } + self.persistWidgetSnapshot(reason: "credits") + } + } + + func cancelScheduledCreditsRefresh() { + self.creditsRefreshTask?.cancel() + self.creditsRefreshTask = nil + self.creditsRefreshTaskKey = nil + } + + func refreshCreditsNow(minimumSnapshotUpdatedAt: Date? = nil) async { + self.cancelScheduledCreditsRefresh() + await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: minimumSnapshotUpdatedAt) + } + + func codexCreditsRefreshKey(expectedGuard: CodexAccountScopedRefreshGuard) -> String { + let sourceKey = switch expectedGuard.source { + case .liveSystem: + "live" + case let .managedAccount(id): + "managed:\(id.uuidString)" + } + + let identityKey = switch expectedGuard.identity { + case let .providerAccount(id): + "provider:\(id)" + case let .emailOnly(normalizedEmail): + "email:\(normalizedEmail)" + case .unresolved: + "unresolved" + } + + return [ + sourceKey, + identityKey, + expectedGuard.accountKey ?? "account:nil", + ].joined(separator: "|") + } + func refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) async { guard self.isEnabled(.codex) else { return } var expectedGuard = self.currentCodexAccountScopedRefreshGuard() @@ -30,6 +91,7 @@ extension UsageStore { } do { let credits = try await self.loadLatestCodexCredits() + guard !Task.isCancelled else { return } guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return } await MainActor.run { self.credits = credits @@ -58,6 +120,7 @@ extension UsageStore { snapshot: codexSnapshot, now: codexSnapshot.updatedAt) } catch { + guard !Task.isCancelled else { return } let message = error.localizedDescription if message.localizedCaseInsensitiveContains("data not available yet") { guard self.shouldApplyCodexScopedNonUsageResult(expectedGuard: expectedGuard) else { return } diff --git a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift index c9a759cb..3df11a94 100644 --- a/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Factory/FactoryProviderImplementation.swift @@ -98,6 +98,6 @@ struct FactoryProviderImplementation: ProviderImplementation { else { return } let balance = UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) - entries.append(.text("Extra usage balance: \(balance)", .primary)) + entries.append(.text(L("Extra usage balance: %@", balance), .primary)) } } diff --git a/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift index 8038ddb8..792830fb 100644 --- a/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Kilo/KiloProviderImplementation.swift @@ -110,6 +110,8 @@ struct KiloProviderImplementation: ProviderImplementation { id: org.id, title: org.name, subtitle: org.role, + localizesTitle: false, + localizesSubtitle: false, isEnabled: settings.kiloIsOrganizationEnabled(org.id), isLocked: false)) } diff --git a/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift b/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift index 738c26ee..4d13b22b 100644 --- a/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift +++ b/Sources/CodexBar/Providers/OpenAI/OpenAIAPIProviderImplementation.swift @@ -15,6 +15,7 @@ struct OpenAIAPIProviderImplementation: ProviderImplementation { @MainActor func observeSettings(_ settings: SettingsStore) { _ = settings.openAIAPIKey + _ = settings.openAIAPIProjectID } @MainActor @@ -52,6 +53,28 @@ struct OpenAIAPIProviderImplementation: ProviderImplementation { ], isVisible: nil, onActivate: nil), + ProviderSettingsFieldDescriptor( + id: "openai-project-id", + title: "Project ID", + subtitle: "Optional. Applies to the configured Admin API key; selected token accounts do not " + + "inherit OPENAI_PROJECT_ID.", + kind: .plain, + placeholder: "proj_...", + binding: context.stringBinding(\.openAIAPIProjectID), + actions: [ + ProviderSettingsActionDescriptor( + id: "openai-open-projects", + title: "Open projects", + style: .link, + isVisible: nil, + perform: { + if let url = URL(string: "https://platform.openai.com/settings/organization/projects") { + NSWorkspace.shared.open(url) + } + }), + ], + isVisible: nil, + onActivate: nil), ] } } diff --git a/Sources/CodexBar/Providers/OpenAI/OpenAIAPISettingsStore.swift b/Sources/CodexBar/Providers/OpenAI/OpenAIAPISettingsStore.swift index b293a75b..7dfb7edf 100644 --- a/Sources/CodexBar/Providers/OpenAI/OpenAIAPISettingsStore.swift +++ b/Sources/CodexBar/Providers/OpenAI/OpenAIAPISettingsStore.swift @@ -11,4 +11,14 @@ extension SettingsStore { self.logSecretUpdate(provider: .openai, field: "apiKey", value: newValue) } } + + var openAIAPIProjectID: String { + get { self.configSnapshot.providerConfig(for: .openai)?.sanitizedWorkspaceID ?? "" } + set { + self.updateProviderConfig(provider: .openai) { entry in + entry.workspaceID = self.normalizedConfigValue(newValue) + } + self.logSecretUpdate(provider: .openai, field: "projectID", value: newValue) + } + } } diff --git a/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift b/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift index 4964f4df..7c3fccc7 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderCookieSourceUI.swift @@ -1,7 +1,7 @@ import CodexBarCore enum ProviderCookieSourceUI { - static let keychainDisabledPrefix = + static let keychainDisabledPrefixKey = "Keychain access is disabled in Advanced, so browser cookie import is unavailable." static func options(allowsOff: Bool, keychainDisabled: Bool) -> [ProviderSettingsPickerOption] { @@ -29,16 +29,88 @@ enum ProviderCookieSourceUI { manual: String, off: String) -> String { + let localizedAuto = self.localizedSubtitle(auto) + let localizedManual = self.localizedSubtitle(manual) + let localizedOff = self.localizedSubtitle(off) if keychainDisabled { - return source == .off ? off : "\(self.keychainDisabledPrefix) \(manual)" + return source == .off + ? localizedOff + : "\(L(self.keychainDisabledPrefixKey)) \(localizedManual)" } switch source { case .auto: - return auto + return localizedAuto case .manual: - return manual + return localizedManual case .off: - return off + return localizedOff } } + + private static func localizedSubtitle(_ subtitle: String) -> String { + let trimmed = subtitle.trimmingCharacters(in: .whitespacesAndNewlines) + if let source = trimmed.removing(prefix: "Paste a Cookie header or cURL capture from ", suffix: ".") { + return L("Paste a Cookie header or cURL capture from %@.", source) + } + if let source = trimmed.removing(prefix: "Paste a Cookie header or full cURL capture from ", suffix: ".") { + return L("Paste a Cookie header or full cURL capture from %@.", source) + } + if let source = trimmed.removing(prefix: "Paste a Cookie header captured from ", suffix: ".") { + return L("Paste a Cookie header captured from %@.", source) + } + if let source = trimmed.removing(prefix: "Paste a Cookie header from ", suffix: ".") { + return L("Paste a Cookie header from %@.", source) + } + if let token = trimmed.removing(prefix: "Paste a full cookie header or the ", suffix: " value.") { + return L("Paste a full cookie header or the %@ value.", token) + } + if let source = trimmed.removing(prefix: "Paste a Cookie or Authorization header from ", suffix: ".") { + return L("Paste a Cookie or Authorization header from %@.", source) + } + if let token = trimmed.removing(prefix: "Paste the ", suffix: " value or a full Cookie header.") { + return L("Paste the %@ value or a full Cookie header.", token) + } + if let token = trimmed.removing(prefix: "Manually paste an ", suffix: " from a browser session.") { + return L("Manually paste an %@ from a browser session.", token) + } + if let token = trimmed.removing( + prefix: "Uses username + password to login and obtain an ", + suffix: " automatically.") + { + return L("Uses username + password to login and obtain an %@ automatically.", token) + } + if let parts = trimmed.removingTwoParts(prefix: "Paste the ", separator: " JSON bundle from ", suffix: ".") { + return L("Paste the %@ JSON bundle from %@.", parts.0, parts.1) + } + if let provider = trimmed.removing(prefix: "Disable ", suffix: " dashboard cookie usage.") { + return L("Disable %@ dashboard cookie usage.", provider) + } + if let provider = trimmed.removing(prefix: "", suffix: " cookies are disabled.") { + return L("%@ cookies are disabled.", provider) + } + if let provider = trimmed.removing(prefix: "", suffix: " authentication is disabled.") { + return L("%@ authentication is disabled.", provider) + } + if let provider = trimmed.removing(prefix: "", suffix: " web API access is disabled.") { + return L("%@ web API access is disabled.", provider) + } + return L(trimmed) + } +} + +extension String { + fileprivate func removing(prefix: String, suffix: String) -> String? { + guard self.hasPrefix(prefix), self.hasSuffix(suffix) else { return nil } + let start = self.index(self.startIndex, offsetBy: prefix.count) + let end = self.index(self.endIndex, offsetBy: -suffix.count) + guard start <= end else { return nil } + return String(self[start.. (String, String)? { + guard let value = self.removing(prefix: prefix, suffix: suffix), + let range = value.range(of: separator) + else { return nil } + return (String(value[.. - + diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index c0c64d9a..6c7a760a 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -72,7 +72,6 @@ "Claude login failed" = "L'inici de sessió de Claude ha fallat"; "Claude login timed out" = "L'inici de sessió de Claude ha esgotat el temps d'espera"; "Close" = "Tanca"; -"Code review" = "Revisió de codi"; "Codex CLI not found" = "No s'ha trobat la CLI de Codex"; "Codex account login already running" = "Ja hi ha un inici de sessió de compte de Codex en curs"; "Codex binary" = "Binari de Codex"; @@ -106,7 +105,6 @@ "Daily Routines" = "Rutines diàries"; "Debug" = "Depuració"; "Default" = "Per defecte"; -"Designs" = "Dissenys"; "Disable Keychain access" = "Desactiva l'accés al Clauer"; "Disabled" = "Desactivat"; "Dismiss" = "Descarta"; @@ -399,6 +397,7 @@ "language_spanish" = "Español"; "language_catalan" = "Català"; "language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; "start_at_login_title" = "Obrir en iniciar la sessió"; "start_at_login_subtitle" = "Obre el CodexBar automàticament en iniciar el Mac."; @@ -453,6 +452,7 @@ "remove" = "Elimina"; "managed_login_already_running" = "Ja hi ha un inici de sessió gestionat de Codex en curs. Espera que acabi abans d'afegir o reautenticar un altre compte."; "managed_login_failed" = "L'inici de sessió gestionat de Codex no s'ha completat. Comprova que `codex --version` funciona al Terminal. Si macOS ha bloquejat o ha mogut `codex` a la Paperera, elimina les instal·lacions duplicades obsoletes, executa `npm install -g --include=optional @openai/codex@latest` i torna-ho a provar."; +"codex_login_output" = "Sortida de codex login:"; "managed_login_missing_email" = "L'inici de sessió de Codex s'ha completat, però no hi havia cap correu de compte disponible. Torna-ho a provar després de confirmar que el compte té la sessió totalment iniciada."; "workspace_selection_cancelled" = "El CodexBar ha trobat diversos espais de treball, però no se n'ha seleccionat cap."; "unsafe_managed_home" = "El CodexBar s'ha negat a modificar un camí de directori gestionat inesperat: %@"; @@ -632,3 +632,287 @@ /* Cost estimation */ "cost_header_estimated" = "Cost (estimat)"; "cost_estimate_hint" = "Estimat a partir de registres locals · pot diferir de la teva factura"; + +/* Popup panels */ +"No usage configured." = "No hi ha cap ús configurat."; +"Quota" = "Quota"; +"tokens" = "tokens"; +"requests" = "sol·licituds"; +"Latest" = "Més recent"; +"Monthly" = "Mensual"; +"Sonnet" = "Sonnet"; +"Auth" = "Autenticació"; +"Overages" = "Excedents"; +"Activity" = "Activitat"; +"Copied" = "Copiat"; +"Copy error" = "Error en copiar"; +"Copy path" = "Copia el camí"; +"Extra usage spent" = "Despesa d'ús addicional"; +"Credits remaining" = "Crèdits restants"; +"Using CLI fallback" = "S'està utilitzant l'alternativa de la CLI"; +"Balance updates in near-real time (up to 5 min lag)" = "El saldo s'actualitza gairebé en temps real (fins a 5 min de retard)"; +"Daily billing data finalizes at 07:00 UTC" = "Les dades diàries de facturació es tanquen a les 07:00 UTC"; +"%@ of %@ credits left" = "Queden %@ de %@ crèdits"; +"%@ of %@ bonus credits left" = "Queden %@ de %@ crèdits de bonificació"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restants)"; +"%@/%@ left" = "%@/%@ restant"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Es regenera %@"; +"used after next regen" = "usat després de la propera regeneració"; +"after next regen" = "després de la propera regeneració"; +"Near full" = "Gairebé ple"; +"Full in ~1 regen" = "Ple en ~1 regeneració"; +"Full in ~%.0f regens" = "Ple en ~%.0f regeneracions"; +"Overage usage" = "Ús excedent"; +"Overage cost" = "Cost excedent"; +"credits" = "crèdits"; +"Zen balance" = "Saldo Zen"; +"API spend" = "Despesa d'API"; +"Extra usage" = "Ús addicional"; +"Quota usage" = "Ús de quota"; +"%.0f%% used" = "%.0f%% usat"; +"Usage history (today)" = "Historial d'ús (avui)"; +"Usage history (%d days)" = "Historial d'ús (%d dies)"; +"%d percent remaining" = "%d%% restant"; +"Unknown" = "Desconegut"; +"stale data" = "dades obsoletes"; +"No credits history data available." = "No hi ha dades d'historial de crèdits disponibles."; +"Credits history chart" = "Gràfic d'historial de crèdits"; +"%d days of credits data" = "%d dies de dades de crèdits"; +"Usage breakdown chart" = "Gràfic de desglossament d'ús"; +"%d days of usage data across %d services" = "%d dies de dades d'ús en %d serveis"; +"Cost history chart" = "Gràfic d'historial de costos"; +"%d days of cost data" = "%d dies de dades de costos"; +"Plan utilization chart" = "Gràfic d'utilització del pla"; +"%d utilization samples" = "%d mostres d'utilització"; +"Hourly Usage" = "Ús per hora"; +"Usage remaining" = "Ús restant"; +"Usage used" = "Ús utilitzat"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "Clau d'API verificada. Ollama no exposa els límits de quota de Cloud a través de l'API."; +"Last 30 days: %@ tokens" = "Últims 30 dies: %@ tokens"; +"7d spend" = "Despesa 7 d"; +"30d spend" = "Despesa 30 d"; +"Cache read" = "Lectura de memòria cau"; +"Claude Admin API 30 day spend trend" = "Tendència de despesa de 30 dies de Claude Admin API"; +"OpenRouter API key spend trend" = "Tendència de despesa de la clau API d'OpenRouter"; +"z.ai hourly token trend" = "Tendència horària de tokens de z.ai"; +"MiniMax 30 day token usage trend" = "Tendència d'ús de tokens de 30 dies de MiniMax"; +"Today cash" = "Efectiu d'avui"; +"DeepSeek 30 day token usage trend" = "Tendència d'ús de tokens de 30 dies de DeepSeek"; +"cache-hit input" = "entrada amb encert de memòria cau"; +"cache-miss input" = "entrada sense encert de memòria cau"; +"output" = "sortida"; +"Requests" = "Sol·licituds"; +"Reported by OpenAI Admin API organization usage." = "Informat per l'ús de l'organització a OpenAI Admin API."; +"Reported by Mistral billing usage." = "Informat per l'ús de facturació de Mistral."; +"Today" = "Avui"; +"Today tokens" = "Tokens d'avui"; +"30d cost" = "Cost 30 d"; +"30d tokens" = "Tokens 30 d"; +"Latest tokens" = "Tokens recents"; +"Top model" = "Model principal"; +"Storage" = "Emmagatzematge"; +"No data" = "Sense dades"; +"Last %d days" = "Últims %d dies"; +"%@ tokens" = "%@ tokens"; +"Latest billing day" = "Últim dia de facturació"; +"Latest billing day (%@)" = "Últim dia de facturació (%@)"; +"This week" = "Aquesta setmana"; +"This month" = "Aquest mes"; +"Week" = "Setmana"; +"Month" = "Mes"; +"Models" = "Models"; +"24h tokens" = "Tokens 24 h"; +"Latest hour" = "Última hora"; +"Peak hour" = "Hora punta"; +"Top method" = "Mètode principal"; +"30d cash" = "Efectiu 30 d"; +"30d billing history from MiniMax web session" = "Historial de facturació de 30 dies de la sessió web de MiniMax"; +"AWS Cost Explorer billing can lag." = "La facturació d'AWS Cost Explorer pot endarrerir-se."; +"Rate limit: %d / %@" = "Límit de taxa: %d / %@"; +"Key remaining" = "Restant de la clau"; +"No limit set for the API key" = "No hi ha cap límit configurat per a la clau API"; +"API key limit unavailable right now" = "El límit de la clau API no està disponible ara mateix"; +"Today: %@ · %@ tokens" = "Avui: %@ · %@ tokens"; +"Today: %@" = "Avui: %@"; +"Today: %@ tokens" = "Avui: %@ tokens"; +"This month: %@ tokens" = "Aquest mes: %@ tokens"; +"API key limit" = "Límit de la clau API"; +"Limits not available" = "Límits no disponibles"; +"No usage yet" = "Encara no hi ha ús"; +"Not fetched yet" = "Encara no obtingut"; +"Code review" = "Revisió de codi"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ espera permís"; +"%@ requests" = "%@ sol·licituds"; +"%@: %@ credits" = "%@: %@ crèdits"; +"30d requests" = "Sol·licituds de 30 d"; +"4 days" = "4 dies"; +"5 days" = "5 dies"; +"7 days" = "7 dies"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "La clau API verifica l'accés a Ollama Cloud; les galetes encara exposen els límits de quota."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID de clau d'accés d'AWS. També es pot definir amb AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Regió d'AWS. També es pot definir amb AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Clau secreta d'accés d'AWS. També es pot definir amb AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "ID de clau d'accés"; +"Add Account" = "Afegeix compte"; +"Adding Account…" = "S'està afegint el compte…"; +"Antigravity login failed" = "L'inici de sessió d'Antigravity ha fallat"; +"Antigravity login timed out" = "L'inici de sessió d'Antigravity ha esgotat el temps"; +"Auth source" = "Font d'autenticació"; +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automàticament les galetes de Chrome de Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automàticament dades de sessió de Windsurf del localStorage de Chromium."; +"Automatic imports browser cookies from Bailian." = "Importa automàticament galetes del navegador de Bailian."; +"Automatically imports browser cookies." = "Importa automàticament galetes del navegador."; +"Automatically imports browser session cookies." = "Importa automàticament galetes de sessió del navegador."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nom del desplegament d'Azure OpenAI. També s'admet AZURE_OPENAI_DEPLOYMENT_NAME."; +"Azure OpenAI key" = "Clau d'Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Endpoint del recurs Azure OpenAI. També s'admet AZURE_OPENAI_ENDPOINT."; +"Base URL" = "URL base"; +"Base URL for the LLM-API-Key-Proxy instance." = "URL base de la instància LLM-API-Key-Proxy."; +"Browser cookies" = "Galetes del navegador"; +"Cap end" = "Final del límit"; +"Cap start" = "Inici del límit"; +"Capacity End" = "Final de capacitat"; +"Capacity Start" = "Inici de capacitat"; +"Changelog" = "Registre de canvis"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Tria el host de l'API Moonshot/Kimi per a comptes internacionals o de la Xina continental."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar no pot substituir un compte del sistema iniciat només amb una clau API."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar no ha trobat autenticació desada per a aquest compte. Torna'l a autenticar i prova-ho de nou."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar no ha pogut llegir l'emmagatzematge de comptes gestionats. Recupera'l abans d'afegir un altre compte."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar no ha pogut llegir l'autenticació desada per a aquest compte. Torna'l a autenticar i prova-ho de nou."; +"CodexBar could not read the current system account on this Mac." = "CodexBar no ha pogut llegir el compte del sistema actual en aquest Mac."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar no ha pogut substituir l'autenticació activa de Codex en aquest Mac."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar no ha pogut preservar de manera segura el compte del sistema actual abans de canviar."; +"CodexBar could not save the current system account before switching." = "CodexBar no ha pogut desar el compte del sistema actual abans de canviar."; +"CodexBar could not update managed account storage." = "CodexBar no ha pogut actualitzar l'emmagatzematge de comptes gestionats."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar ha trobat un altre compte gestionat que ja utilitza el compte del sistema actual. Resol el compte duplicat abans de canviar."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar demanarà a Clauers de macOS “%@” per desxifrar galetes del navegador i autenticar el compte. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS el token OAuth de Claude Code per obtenir l'ús de Claude. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie d'Amp per obtenir l'ús. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie d'Augment per obtenir l'ús. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie de Claude per obtenir l'ús web de Claude. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie de Cursor per obtenir l'ús. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie de Factory per obtenir l'ús. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS el token de GitHub Copilot per obtenir l'ús. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la clau API de Kimi K2 per obtenir l'ús. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS el token d'autenticació de Kimi per obtenir l'ús. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS el token API de MiniMax per obtenir l'ús. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie de MiniMax per obtenir l'ús. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie d'OpenAI per obtenir extres del tauler de Codex. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la capçalera Cookie d'OpenCode per obtenir l'ús. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS la clau API de Synthetic per obtenir l'ús. Fes clic a OK per continuar."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar demanarà a Clauers de macOS el token API de z.ai per obtenir l'ús. Fes clic a OK per continuar."; +"Could not open Cursor login in your browser." = "No s'ha pogut obrir l'inici de sessió de Cursor al navegador."; +"Could not open browser for Antigravity" = "No s'ha pogut obrir el navegador per a Antigravity"; +"Credits used" = "Crèdits usats"; +"Day" = "Dia"; +"Deployment" = "Desplegament"; +"Drag to reorder" = "Arrossega per reordenar"; +"Endpoint" = "Endpoint"; +"Enterprise host" = "Host Enterprise"; +"Extra usage balance: %@" = "Saldo d'ús extra: %@"; +"Keychain Access Required" = "Cal accés a Clauers"; +"Kiro menu bar value" = "Valor de Kiro a la barra de menús"; +"Label" = "Etiqueta"; +"No organizations loaded. Click Refresh after setting your API key." = "No hi ha organitzacions carregades. Fes clic a Actualitza després de configurar la clau API."; +"No output captured." = "No s'ha capturat cap sortida."; +"No system account" = "Sense compte del sistema"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Obre Augment (tanca sessió i torna a entrar)"; +"Open Codebuff Dashboard" = "Obre el tauler de Codebuff"; +"Open Command Code Settings" = "Obre la configuració de Command Code"; +"Open Crof dashboard" = "Obre el tauler de Crof"; +"Open Manus" = "Obre Manus"; +"Open MiMo Balance" = "Obre el saldo de MiMo"; +"Open Moonshot Console" = "Obre la consola de Moonshot"; +"Open Ollama API Keys" = "Obre les claus API d'Ollama"; +"Open StepFun Platform" = "Obre la plataforma StepFun"; +"Open T3 Chat Settings" = "Obre la configuració de T3 Chat"; +"Open Volcengine Ark Console" = "Obre la consola Volcengine Ark"; +"Open legacy provider docs" = "Obre la documentació del proveïdor heretat"; +"Open projects" = "Obre projectes"; +"Open this URL manually to continue login:\n\n%@" = "Obre aquesta URL manualment per continuar l'inici de sessió:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID d'organització opcional per a comptes vinculats a diverses organitzacions d'Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Opcional. S'aplica a la clau Admin API configurada; els comptes de token seleccionats no hereten OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Opcional. Introdueix el host de GitHub Enterprise, per exemple octocorp.ghe.com. Deixa-ho en blanc per a github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opcional. Deixa-ho en blanc per descobrir i agregar projectes visibles per a la clau API."; +"Org ID (optional)" = "ID d'org. (opcional)"; +"Organizations" = "Organitzacions"; +"Password" = "Contrasenya"; +"%@ authentication is disabled." = "L'autenticació de %@ està desactivada."; +"%@ cookies are disabled." = "Les galetes de %@ estan desactivades."; +"%@ web API access is disabled." = "L'accés a l'API web de %@ està desactivat."; +"Disable %@ dashboard cookie usage." = "Desactiva l'ús de galetes del tauler de %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "L'accés al clauer està desactivat a Avançat, així que la importació de galetes del navegador no està disponible."; +"Manually paste an %@ from a browser session." = "Enganxa manualment un %@ d'una sessió del navegador."; +"Paste a Cookie header captured from %@." = "Enganxa una capçalera Cookie capturada de %@."; +"Paste a Cookie header from %@." = "Enganxa una capçalera Cookie de %@."; +"Paste a Cookie header or cURL capture from %@." = "Enganxa una capçalera Cookie o una captura cURL de %@."; +"Paste a Cookie header or full cURL capture from %@." = "Enganxa una capçalera Cookie o una captura cURL completa de %@."; +"Paste a Cookie or Authorization header from %@." = "Enganxa una capçalera Cookie o Authorization de %@."; +"Paste a full cookie header or the %@ value." = "Enganxa una capçalera de galetes completa o el valor %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Enganxa una capçalera Cookie o una captura cURL completa de la configuració de T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Enganxa la capçalera Cookie d'una sol·licitud a admin.mistral.ai. Ha de contenir una galeta ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Enganxa l'Oasis-Token d'una sessió iniciada a platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Enganxa el paquet JSON %@ de %@."; +"Paste the %@ value or a full Cookie header." = "Enganxa el valor %@ o una capçalera Cookie completa."; +"Personal account" = "Compte personal"; +"Project ID" = "ID de projecte"; +"Re-auth" = "Reautentica"; +"Re-authenticating…" = "S'està reautenticant…"; +"Refresh Session" = "Actualitza la sessió"; +"Refresh organizations" = "Actualitza organitzacions"; +"Region" = "Regió"; +"Reload" = "Recarrega"; +"Reorder" = "Reordena"; +"Secret access key" = "Clau secreta d'accés"; +"Series" = "Sèrie"; +"Service" = "Servei"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Mostra o amaga crèdits de Kiro, percentatge o tots dos al costat de la icona de la barra de menús."; +"Show usage for organizations you belong to. Personal account is always shown." = "Mostra l'ús de les organitzacions a què pertanys. El compte personal sempre es mostra."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Inicia sessió a cursor.com al navegador i després actualitza Cursor a CodexBar."; +"Simulated error text" = "Text d'error simulat"; +"StepFun platform account (phone number or email)." = "Compte de la plataforma StepFun (telèfon o correu)."; +"Stored in ~/.codexbar/config.json." = "Desat a ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Desat a ~/.codexbar/config.json. També s'admet AZURE_OPENAI_API_KEY."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Desat a ~/.codexbar/config.json. Per a l'API oficial de Kimi, usa Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Desat a ~/.codexbar/config.json. Obtén la clau API a la consola Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Desat a ~/.codexbar/config.json. Obtén la clau a la configuració d'Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Desat a ~/.codexbar/config.json. Obtén la clau a console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Desat a ~/.codexbar/config.json. Obtén la clau a elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Desat a ~/.codexbar/config.json. Obtén la clau a openrouter.ai/settings/keys i defineix-hi un límit de despesa per activar el seguiment de quota."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Desat a ~/.codexbar/config.json. A Warp, obre Settings > Platform > API Keys i crea'n una."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Desat a ~/.codexbar/config.json. Les mètriques requereixen accés a Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Desat a ~/.codexbar/config.json. Es prefereix OPENAI_ADMIN_KEY; OPENAI_API_KEY encara funciona."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Desat a ~/.codexbar/config.json. Requereix una clau Anthropic Admin API."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Desat a ~/.codexbar/config.json. S'usa per a /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Desat a ~/.codexbar/config.json. També pots proporcionar CODEBUFF_API_KEY o deixar que CodexBar llegeixi ~/.config/manicode/credentials.json (creat per `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Desat a ~/.codexbar/config.json. També pots proporcionar CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Desat a ~/.codexbar/config.json. També pots proporcionar KILO_API_KEY o ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Galeta de T3 Chat"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Aquest compte ja no està disponible a CodexBar. Actualitza la llista de comptes i torna-ho a provar."; +"The browser login did not complete in time. Try Antigravity login again." = "L'inici de sessió del navegador no s'ha completat a temps. Torna a provar l'inici de sessió d'Antigravity."; +"Timed out waiting for Cursor login. %@" = "S'ha esgotat el temps esperant l'inici de sessió de Cursor. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "S'ha esgotat el temps esperant l'inici de sessió de Cursor. %@ Últim error: %@"; +"Today requests" = "Sol·licituds d'avui"; +"Total (30d): %@ credits" = "Total (30 d): %@ crèdits"; +"Username" = "Nom d'usuari"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Usa nom d'usuari i contrasenya per iniciar sessió i obtenir un Oasis-Token automàticament."; +"Uses username + password to login and obtain an %@ automatically." = "Usa nom d'usuari i contrasenya per iniciar sessió i obtenir un %@ automàticament."; +"Utilization End" = "Final d'utilització"; +"Utilization Start" = "Inici d'utilització"; +"Verbosity" = "Detall"; +"Windsurf session JSON bundle" = "Paquet JSON de sessió de Windsurf"; +"Workspace ID" = "ID d'espai de treball"; +"Your StepFun platform password. Used to login and obtain a session token." = "La contrasenya de la plataforma StepFun. S'usa per iniciar sessió i obtenir un token de sessió."; +"claude /login exited with status %d." = "claude /login ha sortit amb estat %d."; +"codex login exited with status %d." = "codex login ha sortit amb estat %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\no enganxa una captura cURL del tauler d'Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\no enganxa el valor de __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\no enganxa el valor del token kimi-auth"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\no enganxa només el valor de session_id"; +"Clear" = "Esborra"; +"No matching providers" = "No hi ha proveïdors coincidents"; +"Search providers" = "Cerca proveïdors"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 9b46eea0..be669770 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -106,7 +106,6 @@ "Daily Routines" = "Daily Routines"; "Debug" = "Debug"; "Default" = "Default"; -"Designs" = "Designs"; "Disable Keychain access" = "Disable Keychain access"; "Disabled" = "Disabled"; "Dismiss" = "Dismiss"; @@ -180,7 +179,6 @@ "No Codex accounts detected yet." = "No Codex accounts detected yet."; "No JetBrains IDE detected" = "No JetBrains IDE detected"; "No cost history data." = "No cost history data."; -"No credits history data." = "No credits history data."; "No data available" = "No data available"; "No data yet" = "No data yet"; "No enabled providers available for Overview." = "No enabled providers available for Overview."; @@ -399,7 +397,9 @@ "language_spanish" = "Español"; "language_catalan" = "Català"; "language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "Svenska"; "start_at_login_title" = "Start at Login"; "start_at_login_subtitle" = "Automatically opens CodexBar when you start your Mac."; "show_cost_summary" = "Show cost summary"; @@ -420,6 +420,13 @@ "quota_warning_session_capitalized" = "Session"; "quota_warning_weekly" = "weekly"; "quota_warning_weekly_capitalized" = "Weekly"; +"quota_warning_notification_title" = "%1$@ %2$@ quota low"; +"quota_warning_notification_body" = "%1$@ left. Reached your %2$d%% %3$@ warning threshold."; +"quota_warning_notification_body_with_account" = "Account %1$@. %2$@ left. Reached your %3$d%% %4$@ warning threshold."; +"session_depleted_notification_title" = "%@ session depleted"; +"session_depleted_notification_body" = "0% left. Will notify when it's available again."; +"session_restored_notification_title" = "%@ session restored"; +"session_restored_notification_body" = "Session quota is available again."; "quota_warning_warn_at" = "Warn at"; "quota_warning_global_threshold_subtitle" = "Remaining percentages for session and weekly windows unless a provider overrides them."; "quota_warning_sound" = "Play notification sound"; @@ -454,7 +461,10 @@ "remove" = "Remove"; "managed_login_already_running" = "A managed Codex login is already running. Wait for it to finish before adding or re-authenticating another account."; "managed_login_failed" = "Managed Codex login did not complete. Verify that `codex --version` works in Terminal. If macOS blocked or moved `codex` to Trash, remove stale duplicate installs, run `npm install -g --include=optional @openai/codex@latest`, then try again."; +"codex_login_output" = "codex login output:"; "managed_login_missing_email" = "Codex login completed, but no account email was available. Try again after confirming the account is fully signed in."; +"login_success_notification_title" = "%@ login successful"; +"login_success_notification_body" = "You can return to the app; authentication finished."; "workspace_selection_cancelled" = "CodexBar found multiple workspaces, but no workspace was selected."; "unsafe_managed_home" = "CodexBar refused to modify an unexpected managed home path: %@"; "menu_bar_metric_title" = "Menu bar metric"; @@ -483,6 +493,8 @@ "show_usage_as_used_subtitle" = "Progress bars fill as you consume quota (instead of showing remaining)."; "show_quota_warning_markers_title" = "Show quota warning markers"; "show_quota_warning_markers_subtitle" = "Draw threshold tick marks on usage bars when quota warnings are configured."; +"weekly_progress_work_days_title" = "Weekly progress work days"; +"weekly_progress_work_days_subtitle" = "Draw day-boundary tick marks on weekly usage bars."; "show_reset_time_as_clock_title" = "Show reset time as clock"; "show_reset_time_as_clock_subtitle" = "Display reset times as absolute clock values instead of countdowns."; "show_provider_changelog_links_title" = "Show provider changelog links"; @@ -656,3 +668,419 @@ /* Cost estimation */ "cost_header_estimated" = "Cost (estimated)"; "cost_estimate_hint" = "Estimated from local logs · may differ from your bill"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Missing DeepSeek API key."; +"%@ is unavailable in the current environment." = "%@ is unavailable in the current environment."; +"All Systems Operational" = "All Systems Operational"; +"Last 30 days" = "Last 30 days"; +"Last 30 days:" = "Last 30 days:"; +"This month" = "This month"; +"Store multiple OpenAI API keys." = "Store multiple OpenAI API keys."; +"Admin API key" = "Admin API key"; +"Open billing" = "Open billing"; +"Google accounts" = "Google accounts"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Store multiple Antigravity Google OAuth accounts for quick switching."; +"Add Google Account" = "Add Google Account"; +"Open Token Plan" = "Open Token Plan"; +"Text Generation" = "Text Generation"; +"Text to Speech" = "Text to Speech"; +"Music Generation" = "Music Generation"; +"Image Generation" = "Image Generation"; +"No local data found" = "No local data found"; +"Credits unavailable; keep Codex running to refresh." = "Credits unavailable; keep Codex running to refresh."; +"No available fetch strategy for minimax." = "No available fetch strategy for minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)."; +"No OpenCode session cookies found in browsers." = "No OpenCode session cookies found in browsers."; +"No available fetch strategy for %@." = "No available fetch strategy for %@."; +"Today" = "Today"; +"Today tokens" = "Today tokens"; +"30d cost" = "30d cost"; +"30d tokens" = "30d tokens"; +"Latest tokens" = "Latest tokens"; +"Top model" = "Top model"; +"Storage" = "Storage"; +"Add Account..." = "Add Account..."; +"Usage Dashboard" = "Usage Dashboard"; +"Status Page" = "Status Page"; +"Settings..." = "Settings..."; +"About CodexBar" = "About CodexBar"; +"Quit" = "Quit"; +"Last %d day" = "Last %d day"; +"Last %d days" = "Last %d days"; +"%@ tokens" = "%@ tokens"; +"Latest billing day" = "Latest billing day"; +"Latest billing day (%@)" = "Latest billing day (%@)"; +"%@ left" = "%@ left"; +"Resets %@" = "Resets %@"; +"Resets in %@" = "Resets in %@"; +"Resets now" = "Resets now"; +"Lasts until reset" = "Lasts until reset"; +"Updated %@" = "Updated %@"; +"Updated %@h ago" = "Updated %@h ago"; +"Updated %@m ago" = "Updated %@m ago"; +"Updated just now" = "Updated just now"; +"Projected empty in %@" = "Projected empty in %@"; +"Runs out in %@" = "Runs out in %@"; +"Pace: %@" = "Pace: %@"; +"Pace: %@ · %@" = "Pace: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% run-out risk"; +"%d%% in deficit" = "%d%% in deficit"; +"%d%% in reserve" = "%d%% in reserve"; +"usage_percent_suffix_left" = "left"; +"usage_percent_suffix_used" = "used"; +"Store multiple DeepSeek API keys." = "Store multiple DeepSeek API keys."; +"This week" = "This week"; +"Week" = "Week"; +"Month" = "Month"; +"Models" = "Models"; +"24h tokens" = "24h tokens"; +"Latest hour" = "Latest hour"; +"Peak hour" = "Peak hour"; +"Top method" = "Top method"; +"30d cash" = "30d cash"; +"30d billing history from MiniMax web session" = "30d billing history from MiniMax web session"; +"AWS Cost Explorer billing can lag." = "AWS Cost Explorer billing can lag."; +"Rate limit: %d / %@" = "Rate limit: %d / %@"; +"Key remaining" = "Key remaining"; +"No limit set for the API key" = "No limit set for the API key"; +"API key limit unavailable right now" = "API key limit unavailable right now"; +"This month: %@ tokens" = "This month: %@ tokens"; +"No utilization data yet." = "No utilization data yet."; +"No %@ utilization data yet." = "No %@ utilization data yet."; +"%@: %@%% used" = "%@: %@%% used"; +"%dd" = "%dd"; +"today" = "today"; +"just now" = "just now"; +"On pace" = "On pace"; +"Runs out now" = "Runs out now"; +"Projected empty now" = "Projected empty now"; +"Switch Account..." = "Switch Account..."; +"Update ready, restart now?" = "Update ready, restart now?"; +"Daily" = "Daily"; +"Hourly Tokens" = "Hourly Tokens"; +"No data" = "No data"; +"No usage breakdown data available." = "No usage breakdown data available."; + +"Today: %@ · %@ tokens" = "Today: %@ · %@ tokens"; +"Today: %@" = "Today: %@"; +"Today: %@ tokens" = "Today: %@ tokens"; +"Last 30 days: %@ · %@ tokens" = "Last 30 days: %@ · %@ tokens"; +"Last 30 days: %@" = "Last 30 days: %@"; +"Est. total (30d): %@" = "Est. total (30d): %@"; +"Est. total (%@): %@" = "Est. total (%@): %@"; +"Hover a bar for details" = "Hover a bar for details"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; +"No providers selected for Overview." = "No providers selected for Overview."; +"No overview data available." = "No overview data available."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto uses the local IDE API first, then Google OAuth when the IDE is closed."; +"Login with Google" = "Login with Google"; + +/* Popup panels */ +"No usage configured." = "No usage configured."; +"Quota" = "Quota"; +"tokens" = "tokens"; +"requests" = "requests"; +"Latest" = "Latest"; +"Monthly" = "Monthly"; +"Sonnet" = "Sonnet"; +"Overages" = "Overages"; +"Activity" = "Activity"; +"Copied" = "Copied"; +"Copy error" = "Copy error"; +"Copy path" = "Copy path"; +"Extra usage spent" = "Extra usage spent"; +"Credits remaining" = "Credits remaining"; +"Using CLI fallback" = "Using CLI fallback"; +"Balance updates in near-real time (up to 5 min lag)" = "Balance updates in near-real time (up to 5 min lag)"; +"Daily billing data finalizes at 07:00 UTC" = "Daily billing data finalizes at 07:00 UTC"; +"%@ of %@ credits left" = "%@ of %@ credits left"; +"%@ of %@ bonus credits left" = "%@ of %@ bonus credits left"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ remaining)"; +"%@/%@ left" = "%@/%@ left"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Regenerates %@"; +"used after next regen" = "used after next regen"; +"after next regen" = "after next regen"; +"Near full" = "Near full"; +"Full in ~1 regen" = "Full in ~1 regen"; +"Full in ~%.0f regens" = "Full in ~%.0f regens"; +"Overage usage" = "Overage usage"; +"Overage cost" = "Overage cost"; +"credits" = "credits"; +"Zen balance" = "Zen balance"; +"API spend" = "API spend"; +"Extra usage" = "Extra usage"; +"Quota usage" = "Quota usage"; +"%.0f%% used" = "%.0f%% used"; +"Usage history (today)" = "Usage history (today)"; +"Usage history (%d days)" = "Usage history (%d days)"; +"%d percent remaining" = "%d percent remaining"; +"Unknown" = "Unknown"; +"stale data" = "stale data"; +"No credits history data." = "No credits history data."; +"No credits history data available." = "No credits history data available."; +"Credits history chart" = "Credits history chart"; +"%d days of credits data" = "%d days of credits data"; +"Usage breakdown chart" = "Usage breakdown chart"; +"%d days of usage data across %d services" = "%d days of usage data across %d services"; +"Cost history chart" = "Cost history chart"; +"%d days of cost data" = "%d days of cost data"; +"Plan utilization chart" = "Plan utilization chart"; +"%d utilization samples" = "%d utilization samples"; +"Hourly Usage" = "Hourly Usage"; +"Usage remaining" = "Usage remaining"; +"Usage used" = "Usage used"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "API key verified. Ollama does not expose Cloud quota limits through the API."; +"Last 30 days: %@ tokens" = "Last 30 days: %@ tokens"; +"7d spend" = "7d spend"; +"30d spend" = "30d spend"; +"Cache read" = "Cache read"; +"Claude Admin API 30 day spend trend" = "Claude Admin API 30 day spend trend"; +"OpenRouter API key spend trend" = "OpenRouter API key spend trend"; +"z.ai hourly token trend" = "z.ai hourly token trend"; +"MiniMax 30 day token usage trend" = "MiniMax 30 day token usage trend"; +"Today cash" = "Today cash"; +"DeepSeek 30 day token usage trend" = "DeepSeek 30 day token usage trend"; +"cache-hit input" = "cache-hit input"; +"cache-miss input" = "cache-miss input"; +"output" = "output"; +"Requests" = "Requests"; +"Reported by OpenAI Admin API organization usage." = "Reported by OpenAI Admin API organization usage."; +"Reported by Mistral billing usage." = "Reported by Mistral billing usage."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Add accounts via GitHub OAuth Device Flow on the selected host."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override."; +"Manual cleanup: past sessions" = "Manual cleanup: past sessions"; +"Clearing removes past resume, continue, and rewind history." = "Clearing removes past resume, continue, and rewind history."; +"Manual cleanup: file checkpoints" = "Manual cleanup: file checkpoints"; +"Clearing removes checkpoint restore data for previous edits." = "Clearing removes checkpoint restore data for previous edits."; +"Manual cleanup: saved plans" = "Manual cleanup: saved plans"; +"Clearing removes old plan-mode files." = "Clearing removes old plan-mode files."; +"Manual cleanup: debug logs" = "Manual cleanup: debug logs"; +"Clearing removes past debug logs." = "Clearing removes past debug logs."; +"Manual cleanup: attachment cache" = "Manual cleanup: attachment cache"; +"Clearing removes cached large pastes or attached images." = "Clearing removes cached large pastes or attached images."; +"Manual cleanup: session metadata" = "Manual cleanup: session metadata"; +"Clearing removes per-session environment metadata." = "Clearing removes per-session environment metadata."; +"Manual cleanup: shell snapshots" = "Manual cleanup: shell snapshots"; +"Clearing removes leftover runtime shell snapshot files." = "Clearing removes leftover runtime shell snapshot files."; +"Manual cleanup: legacy todos" = "Manual cleanup: legacy todos"; +"Clearing removes legacy per-session task lists." = "Clearing removes legacy per-session task lists."; +"Manual cleanup: sessions" = "Manual cleanup: sessions"; +"Clearing removes past Codex session history." = "Clearing removes past Codex session history."; +"Manual cleanup: archived sessions" = "Manual cleanup: archived sessions"; +"Clearing removes archived Codex session history." = "Clearing removes archived Codex session history."; +"Manual cleanup: cache" = "Manual cleanup: cache"; +"Clearing removes provider-owned cached data." = "Clearing removes provider-owned cached data."; +"Manual cleanup: logs" = "Manual cleanup: logs"; +"Clearing removes local diagnostic logs." = "Clearing removes local diagnostic logs."; +"Manual cleanup: file history" = "Manual cleanup: file history"; +"Clearing removes local edit checkpoint history." = "Clearing removes local edit checkpoint history."; +"Manual cleanup: temporary data" = "Manual cleanup: temporary data"; +"Clearing removes local temporary provider data." = "Clearing removes local temporary provider data."; +"Total: %@" = "Total: %@"; +"%d more items" = "%d more items"; +"Cleanup ideas" = "Cleanup ideas"; +"%d unreadable item(s) skipped" = "%d unreadable item(s) skipped"; + +"API key limit" = "API key limit"; +"Auth" = "Auth"; +"Auto" = "Auto"; +"Disabled — no recent data" = "Disabled — no recent data"; +"Limits not available" = "Limits not available"; +"No usage yet" = "No usage yet"; +"Not fetched yet" = "Not fetched yet"; +"Refreshing" = "Refreshing"; +"Session" = "Session"; +"Source" = "Source"; +"State" = "State"; +"Unavailable" = "Unavailable"; +"Weekly" = "Weekly"; +"not detected" = "not detected"; +"Estimated from local Codex logs for the selected account." = "Estimated from local Codex logs for the selected account."; +"minimax_usage_amount_format" = "Usage: %@ / %@"; +"minimax_used_percent_format" = "Used %@"; +"minimax_service_text_generation" = "Text Generation"; +"minimax_service_text_to_speech" = "Text to Speech"; +"minimax_service_music_generation" = "Music Generation"; +"minimax_service_image_generation" = "Image Generation"; +"minimax_service_lyrics_generation" = "Lyrics generation"; +"minimax_service_coding_plan_vlm" = "Coding plan VLM"; +"minimax_service_coding_plan_search" = "Coding plan search"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ is waiting for permission"; +"%@ requests" = "%@ requests"; +"%@: %@ credits" = "%@: %@ credits"; +"30d requests" = "30d requests"; +"4 days" = "4 days"; +"5 days" = "5 days"; +"7 days" = "7 days"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API key verifies Ollama Cloud access; cookies still expose quota limits."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "AWS region. Can also be set with AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "Access key ID"; +"Add Account" = "Add Account"; +"Adding Account…" = "Adding Account…"; +"Antigravity login failed" = "Antigravity login failed"; +"Antigravity login timed out" = "Antigravity login timed out"; +"Auth source" = "Auth source"; +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Automatic imports Chrome browser cookies from Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Automatic imports Windsurf session data from Chromium browser localStorage."; +"Automatic imports browser cookies from Bailian." = "Automatic imports browser cookies from Bailian."; +"Automatically imports browser cookies." = "Automatically imports browser cookies."; +"Automatically imports browser session cookies." = "Automatically imports browser session cookies."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported."; +"Azure OpenAI key" = "Azure OpenAI key"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported."; +"Base URL" = "Base URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "Base URL for the LLM-API-Key-Proxy instance."; +"Browser cookies" = "Browser cookies"; +"Cap end" = "Cap end"; +"Cap start" = "Cap start"; +"Capacity End" = "Capacity End"; +"Capacity Start" = "Capacity Start"; +"Changelog" = "Changelog"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Choose the Moonshot/Kimi API host for international or China mainland accounts."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar can't replace a system account that is signed in with an API key only setup."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar could not find saved auth for that account. Re-authenticate it and try again."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar could not read managed account storage. Recover the store before adding another account."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar could not read saved auth for that account. Re-authenticate it and try again."; +"CodexBar could not read the current system account on this Mac." = "CodexBar could not read the current system account on this Mac."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar could not replace the live Codex auth on this Mac."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar could not safely preserve the current system account before switching."; +"CodexBar could not save the current system account before switching." = "CodexBar could not save the current system account before switching."; +"CodexBar could not update managed account storage." = "CodexBar could not update managed account storage."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue."; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue."; +"Could not open Cursor login in your browser." = "Could not open Cursor login in your browser."; +"Could not open browser for Antigravity" = "Could not open browser for Antigravity"; +"Credits used" = "Credits used"; +"Day" = "Day"; +"Deployment" = "Deployment"; +"Drag to reorder" = "Drag to reorder"; +"Endpoint" = "Endpoint"; +"Enterprise host" = "Enterprise host"; +"Extra usage balance: %@" = "Extra usage balance: %@"; +"Keychain Access Required" = "Keychain Access Required"; +"Kiro menu bar value" = "Kiro menu bar value"; +"Label" = "Label"; +"No organizations loaded. Click Refresh after setting your API key." = "No organizations loaded. Click Refresh after setting your API key."; +"No output captured." = "No output captured."; +"No system account" = "No system account"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Open Augment (Log Out & Back In)"; +"Open Codebuff Dashboard" = "Open Codebuff Dashboard"; +"Open Command Code Settings" = "Open Command Code Settings"; +"Open Crof dashboard" = "Open Crof dashboard"; +"Open Manus" = "Open Manus"; +"Open MiMo Balance" = "Open MiMo Balance"; +"Open Moonshot Console" = "Open Moonshot Console"; +"Open Ollama API Keys" = "Open Ollama API Keys"; +"Open StepFun Platform" = "Open StepFun Platform"; +"Open T3 Chat Settings" = "Open T3 Chat Settings"; +"Open Volcengine Ark Console" = "Open Volcengine Ark Console"; +"Open legacy provider docs" = "Open legacy provider docs"; +"Open projects" = "Open projects"; +"Open this URL manually to continue login:\n\n%@" = "Open this URL manually to continue login:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "Optional organization ID for accounts linked to multiple Anthropic organizations."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Optional. Leave blank to discover and aggregate projects visible to the API key."; +"Org ID (optional)" = "Org ID (optional)"; +"Organizations" = "Organizations"; +"Password" = "Password"; +"%@ authentication is disabled." = "%@ authentication is disabled."; +"%@ cookies are disabled." = "%@ cookies are disabled."; +"%@ web API access is disabled." = "%@ web API access is disabled."; +"Disable %@ dashboard cookie usage." = "Disable %@ dashboard cookie usage."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Keychain access is disabled in Advanced, so browser cookie import is unavailable."; +"Manually paste an %@ from a browser session." = "Manually paste an %@ from a browser session."; +"Paste a Cookie header captured from %@." = "Paste a Cookie header captured from %@."; +"Paste a Cookie header from %@." = "Paste a Cookie header from %@."; +"Paste a Cookie header or cURL capture from %@." = "Paste a Cookie header or cURL capture from %@."; +"Paste a Cookie header or full cURL capture from %@." = "Paste a Cookie header or full cURL capture from %@."; +"Paste a Cookie or Authorization header from %@." = "Paste a Cookie or Authorization header from %@."; +"Paste a full cookie header or the %@ value." = "Paste a full cookie header or the %@ value."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Paste a Cookie header or full cURL capture from T3 Chat settings."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Paste the %@ JSON bundle from %@."; +"Paste the %@ value or a full Cookie header." = "Paste the %@ value or a full Cookie header."; +"Personal account" = "Personal account"; +"Project ID" = "Project ID"; +"Re-auth" = "Re-auth"; +"Re-authenticating…" = "Re-authenticating…"; +"Refresh Session" = "Refresh Session"; +"Refresh organizations" = "Refresh organizations"; +"Region" = "Region"; +"Reload" = "Reload"; +"Reorder" = "Reorder"; +"Secret access key" = "Secret access key"; +"Series" = "Series"; +"Service" = "Service"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Show or hide Kiro credits, percent, or both next to the menu bar icon."; +"Show usage for organizations you belong to. Personal account is always shown." = "Show usage for organizations you belong to. Personal account is always shown."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Sign in to cursor.com in your browser, then refresh Cursor in CodexBar."; +"Simulated error text" = "Simulated error text"; +"StepFun platform account (phone number or email)." = "StepFun platform account (phone number or email)."; +"Stored in ~/.codexbar/config.json." = "Stored in ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Stored in ~/.codexbar/config.json. Get your key from Ollama settings."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Stored in ~/.codexbar/config.json. Used for /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "T3 Chat cookie"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "That account is no longer available in CodexBar. Refresh the account list and try again."; +"The browser login did not complete in time. Try Antigravity login again." = "The browser login did not complete in time. Try Antigravity login again."; +"Timed out waiting for Cursor login. %@" = "Timed out waiting for Cursor login. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Timed out waiting for Cursor login. %@ Last error: %@"; +"Today requests" = "Today requests"; +"Total (30d): %@ credits" = "Total (30d): %@ credits"; +"Username" = "Username"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Uses username + password to login and obtain an Oasis-Token automatically."; +"Uses username + password to login and obtain an %@ automatically." = "Uses username + password to login and obtain an %@ automatically."; +"Utilization End" = "Utilization End"; +"Utilization Start" = "Utilization Start"; +"Verbosity" = "Verbosity"; +"Windsurf session JSON bundle" = "Windsurf session JSON bundle"; +"Workspace ID" = "Workspace ID"; +"Your StepFun platform password. Used to login and obtain a session token." = "Your StepFun platform password. Used to login and obtain a session token."; +"claude /login exited with status %d." = "claude /login exited with status %d."; +"codex login exited with status %d." = "codex login exited with status %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nor paste the __Secure-next-auth.session-token value"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nor paste the kimi-auth token value"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nor paste just the session_id value"; +"Clear" = "Clear"; +"No matching providers" = "No matching providers"; +"Search providers" = "Search providers"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 01f7c8cb..293183e7 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -72,7 +72,6 @@ "Claude login failed" = "El inicio de sesión de Claude falló"; "Claude login timed out" = "El inicio de sesión de Claude agotó el tiempo de espera"; "Close" = "Cerrar"; -"Code review" = "Revisión de código"; "Codex CLI not found" = "No se encontró la CLI de Codex"; "Codex account login already running" = "Ya hay un inicio de sesión de cuenta de Codex en curso"; "Codex binary" = "Binario de Codex"; @@ -106,7 +105,6 @@ "Daily Routines" = "Rutinas diarias"; "Debug" = "Depuración"; "Default" = "Predeterminado"; -"Designs" = "Diseños"; "Disable Keychain access" = "Desactivar el acceso al Llavero"; "Disabled" = "Desactivado"; "Dismiss" = "Descartar"; @@ -399,6 +397,7 @@ "language_spanish" = "Español"; "language_catalan" = "Català"; "language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; "start_at_login_title" = "Abrir al iniciar sesión"; "start_at_login_subtitle" = "Abre CodexBar automáticamente al iniciar tu Mac."; @@ -453,6 +452,7 @@ "remove" = "Eliminar"; "managed_login_already_running" = "Ya hay un inicio de sesión gestionado de Codex en curso. Espera a que termine antes de añadir o reautenticar otra cuenta."; "managed_login_failed" = "El inicio de sesión gestionado de Codex no se completó. Comprueba que `codex --version` funciona en la Terminal. Si macOS bloqueó o movió `codex` a la Papelera, elimina instalaciones duplicadas obsoletas, ejecuta `npm install -g --include=optional @openai/codex@latest` y vuelve a intentarlo."; +"codex_login_output" = "Salida de codex login:"; "managed_login_missing_email" = "El inicio de sesión de Codex se completó, pero no había ningún correo de cuenta disponible. Inténtalo de nuevo tras confirmar que la cuenta tiene la sesión totalmente iniciada."; "workspace_selection_cancelled" = "CodexBar encontró varios espacios de trabajo, pero no se seleccionó ninguno."; "unsafe_managed_home" = "CodexBar se negó a modificar una ruta de directorio gestionado inesperada: %@"; @@ -632,3 +632,287 @@ /* Cost estimation */ "cost_header_estimated" = "Coste (estimado)"; "cost_estimate_hint" = "Estimado a partir de registros locales · puede diferir de tu factura"; + +/* Popup panels */ +"No usage configured." = "No hay uso configurado."; +"Quota" = "Cuota"; +"tokens" = "tokens"; +"requests" = "solicitudes"; +"Latest" = "Último"; +"Monthly" = "Mensual"; +"Sonnet" = "Sonnet"; +"Auth" = "Autenticación"; +"Overages" = "Excesos"; +"Activity" = "Actividad"; +"Copied" = "Copiado"; +"Copy error" = "Error al copiar"; +"Copy path" = "Copiar ruta"; +"Extra usage spent" = "Gasto de uso adicional"; +"Credits remaining" = "Créditos restantes"; +"Using CLI fallback" = "Usando alternativa de CLI"; +"Balance updates in near-real time (up to 5 min lag)" = "El saldo se actualiza casi en tiempo real (hasta 5 min de retraso)"; +"Daily billing data finalizes at 07:00 UTC" = "Los datos diarios de facturación se cierran a las 07:00 UTC"; +"%@ of %@ credits left" = "Quedan %@ de %@ créditos"; +"%@ of %@ bonus credits left" = "Quedan %@ de %@ créditos de bonificación"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restante)"; +"%@/%@ left" = "%@/%@ restante"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Se regenera %@"; +"used after next regen" = "usado tras la próxima regeneración"; +"after next regen" = "tras la próxima regeneración"; +"Near full" = "Casi lleno"; +"Full in ~1 regen" = "Lleno en ~1 regeneración"; +"Full in ~%.0f regens" = "Lleno en ~%.0f regeneraciones"; +"Overage usage" = "Uso excedente"; +"Overage cost" = "Coste excedente"; +"credits" = "créditos"; +"Zen balance" = "Saldo Zen"; +"API spend" = "Gasto de API"; +"Extra usage" = "Uso adicional"; +"Quota usage" = "Uso de cuota"; +"%.0f%% used" = "%.0f%% usado"; +"Usage history (today)" = "Historial de uso (hoy)"; +"Usage history (%d days)" = "Historial de uso (%d días)"; +"%d percent remaining" = "%d%% restante"; +"Unknown" = "Desconocido"; +"stale data" = "datos obsoletos"; +"No credits history data available." = "No hay datos de historial de créditos disponibles."; +"Credits history chart" = "Gráfico de historial de créditos"; +"%d days of credits data" = "%d días de datos de créditos"; +"Usage breakdown chart" = "Gráfico de desglose de uso"; +"%d days of usage data across %d services" = "%d días de datos de uso en %d servicios"; +"Cost history chart" = "Gráfico de historial de costes"; +"%d days of cost data" = "%d días de datos de costes"; +"Plan utilization chart" = "Gráfico de uso del plan"; +"%d utilization samples" = "%d muestras de uso"; +"Hourly Usage" = "Uso por hora"; +"Usage remaining" = "Uso restante"; +"Usage used" = "Uso utilizado"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "Clave de API verificada. Ollama no expone los límites de cuota de Cloud mediante la API."; +"Last 30 days: %@ tokens" = "Últimos 30 días: %@ tokens"; +"7d spend" = "Gasto 7 d"; +"30d spend" = "Gasto 30 d"; +"Cache read" = "Lectura de caché"; +"Claude Admin API 30 day spend trend" = "Tendencia de gasto de 30 días de Claude Admin API"; +"OpenRouter API key spend trend" = "Tendencia de gasto de la clave API de OpenRouter"; +"z.ai hourly token trend" = "Tendencia horaria de tokens de z.ai"; +"MiniMax 30 day token usage trend" = "Tendencia de uso de tokens de 30 días de MiniMax"; +"Today cash" = "Efectivo de hoy"; +"DeepSeek 30 day token usage trend" = "Tendencia de uso de tokens de 30 días de DeepSeek"; +"cache-hit input" = "entrada con acierto de caché"; +"cache-miss input" = "entrada sin acierto de caché"; +"output" = "salida"; +"Requests" = "Solicitudes"; +"Reported by OpenAI Admin API organization usage." = "Informado por el uso de la organización en OpenAI Admin API."; +"Reported by Mistral billing usage." = "Informado por el uso de facturación de Mistral."; +"Today" = "Hoy"; +"Today tokens" = "Tokens de hoy"; +"30d cost" = "Coste 30 d"; +"30d tokens" = "Tokens 30 d"; +"Latest tokens" = "Tokens recientes"; +"Top model" = "Modelo principal"; +"Storage" = "Almacenamiento"; +"No data" = "Sin datos"; +"Last %d days" = "Últimos %d días"; +"%@ tokens" = "%@ tokens"; +"Latest billing day" = "Último día de facturación"; +"Latest billing day (%@)" = "Último día de facturación (%@)"; +"This week" = "Esta semana"; +"This month" = "Este mes"; +"Week" = "Semana"; +"Month" = "Mes"; +"Models" = "Modelos"; +"24h tokens" = "Tokens 24 h"; +"Latest hour" = "Última hora"; +"Peak hour" = "Hora pico"; +"Top method" = "Método principal"; +"30d cash" = "Efectivo 30 d"; +"30d billing history from MiniMax web session" = "Historial de facturación de 30 días de la sesión web de MiniMax"; +"AWS Cost Explorer billing can lag." = "La facturación de AWS Cost Explorer puede retrasarse."; +"Rate limit: %d / %@" = "Límite de tasa: %d / %@"; +"Key remaining" = "Clave restante"; +"No limit set for the API key" = "No hay límite configurado para la clave API"; +"API key limit unavailable right now" = "El límite de la clave API no está disponible ahora"; +"Today: %@ · %@ tokens" = "Hoy: %@ · %@ tokens"; +"Today: %@" = "Hoy: %@"; +"Today: %@ tokens" = "Hoy: %@ tokens"; +"This month: %@ tokens" = "Este mes: %@ tokens"; +"API key limit" = "Límite de clave API"; +"Limits not available" = "Límites no disponibles"; +"No usage yet" = "Aún no hay uso"; +"Not fetched yet" = "Aún no obtenido"; +"Code review" = "Revisión de código"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ espera permiso"; +"%@ requests" = "%@ solicitudes"; +"%@: %@ credits" = "%@: %@ créditos"; +"30d requests" = "Solicitudes de 30 d"; +"4 days" = "4 días"; +"5 days" = "5 días"; +"7 days" = "7 días"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "La clave API verifica el acceso a Ollama Cloud; las cookies aún muestran los límites de cuota."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID de clave de acceso de AWS. También puede definirse con AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Región de AWS. También puede definirse con AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Clave secreta de AWS. También puede definirse con AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "ID de clave de acceso"; +"Add Account" = "Añadir cuenta"; +"Adding Account…" = "Añadiendo cuenta…"; +"Antigravity login failed" = "Error al iniciar sesión en Antigravity"; +"Antigravity login timed out" = "El inicio de sesión en Antigravity agotó el tiempo"; +"Auth source" = "Fuente de autenticación"; +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automáticamente las cookies de Chrome desde Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automáticamente datos de sesión de Windsurf desde localStorage de Chromium."; +"Automatic imports browser cookies from Bailian." = "Importa automáticamente cookies del navegador desde Bailian."; +"Automatically imports browser cookies." = "Importa automáticamente cookies del navegador."; +"Automatically imports browser session cookies." = "Importa automáticamente cookies de sesión del navegador."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nombre de despliegue de Azure OpenAI. También se admite AZURE_OPENAI_DEPLOYMENT_NAME."; +"Azure OpenAI key" = "Clave de Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Endpoint del recurso de Azure OpenAI. También se admite AZURE_OPENAI_ENDPOINT."; +"Base URL" = "URL base"; +"Base URL for the LLM-API-Key-Proxy instance." = "URL base de la instancia de LLM-API-Key-Proxy."; +"Browser cookies" = "Cookies del navegador"; +"Cap end" = "Fin del límite"; +"Cap start" = "Inicio del límite"; +"Capacity End" = "Fin de capacidad"; +"Capacity Start" = "Inicio de capacidad"; +"Changelog" = "Registro de cambios"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Elige el host de la API Moonshot/Kimi para cuentas internacionales o de China continental."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar no puede reemplazar una cuenta del sistema iniciada solo con una clave API."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar no encontró autenticación guardada para esa cuenta. Vuelve a autenticarla e inténtalo de nuevo."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar no pudo leer el almacenamiento de cuentas gestionadas. Recupera el almacén antes de añadir otra cuenta."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar no pudo leer la autenticación guardada para esa cuenta. Vuelve a autenticarla e inténtalo de nuevo."; +"CodexBar could not read the current system account on this Mac." = "CodexBar no pudo leer la cuenta del sistema actual en este Mac."; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar no pudo reemplazar la autenticación activa de Codex en este Mac."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar no pudo preservar con seguridad la cuenta del sistema actual antes de cambiar."; +"CodexBar could not save the current system account before switching." = "CodexBar no pudo guardar la cuenta del sistema actual antes de cambiar."; +"CodexBar could not update managed account storage." = "CodexBar no pudo actualizar el almacenamiento de cuentas gestionadas."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar encontró otra cuenta gestionada que ya usa la cuenta del sistema actual. Resuelve la cuenta duplicada antes de cambiar."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS “%@” para descifrar cookies del navegador y autenticar tu cuenta. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS el token OAuth de Claude Code para obtener tu uso de Claude. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu cabecera Cookie de Amp para obtener el uso. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu cabecera Cookie de Augment para obtener el uso. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu cabecera Cookie de Claude para obtener el uso web de Claude. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu cabecera Cookie de Cursor para obtener el uso. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu cabecera Cookie de Factory para obtener el uso. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu token de GitHub Copilot para obtener el uso. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu clave API de Kimi K2 para obtener el uso. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu token de autenticación de Kimi para obtener el uso. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu token API de MiniMax para obtener el uso. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu cabecera Cookie de MiniMax para obtener el uso. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu cabecera Cookie de OpenAI para obtener extras del panel de Codex. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu cabecera Cookie de OpenCode para obtener el uso. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu clave API de Synthetic para obtener el uso. Haz clic en OK para continuar."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar pedirá a Llaveros de macOS tu token API de z.ai para obtener el uso. Haz clic en OK para continuar."; +"Could not open Cursor login in your browser." = "No se pudo abrir el inicio de sesión de Cursor en el navegador."; +"Could not open browser for Antigravity" = "No se pudo abrir el navegador para Antigravity"; +"Credits used" = "Créditos usados"; +"Day" = "Día"; +"Deployment" = "Despliegue"; +"Drag to reorder" = "Arrastra para reordenar"; +"Endpoint" = "Endpoint"; +"Enterprise host" = "Host Enterprise"; +"Extra usage balance: %@" = "Saldo de uso extra: %@"; +"Keychain Access Required" = "Se requiere acceso a Llaveros"; +"Kiro menu bar value" = "Valor de Kiro en la barra de menús"; +"Label" = "Etiqueta"; +"No organizations loaded. Click Refresh after setting your API key." = "No hay organizaciones cargadas. Haz clic en Actualizar después de configurar tu clave API."; +"No output captured." = "No se capturó salida."; +"No system account" = "Sin cuenta del sistema"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Abrir Augment (cerrar sesión y volver a entrar)"; +"Open Codebuff Dashboard" = "Abrir panel de Codebuff"; +"Open Command Code Settings" = "Abrir ajustes de Command Code"; +"Open Crof dashboard" = "Abrir panel de Crof"; +"Open Manus" = "Abrir Manus"; +"Open MiMo Balance" = "Abrir saldo de MiMo"; +"Open Moonshot Console" = "Abrir consola de Moonshot"; +"Open Ollama API Keys" = "Abrir claves API de Ollama"; +"Open StepFun Platform" = "Abrir plataforma StepFun"; +"Open T3 Chat Settings" = "Abrir ajustes de T3 Chat"; +"Open Volcengine Ark Console" = "Abrir consola Volcengine Ark"; +"Open legacy provider docs" = "Abrir documentación del proveedor heredado"; +"Open projects" = "Abrir proyectos"; +"Open this URL manually to continue login:\n\n%@" = "Abre esta URL manualmente para continuar el inicio de sesión:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID de organización opcional para cuentas vinculadas a varias organizaciones de Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Opcional. Se aplica a la clave Admin API configurada; las cuentas de token seleccionadas no heredan OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Opcional. Introduce tu host de GitHub Enterprise, por ejemplo octocorp.ghe.com. Déjalo vacío para github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opcional. Déjalo vacío para descubrir y agregar proyectos visibles para la clave API."; +"Org ID (optional)" = "ID de org. (opcional)"; +"Organizations" = "Organizaciones"; +"Password" = "Contraseña"; +"%@ authentication is disabled." = "La autenticación de %@ está desactivada."; +"%@ cookies are disabled." = "Las cookies de %@ están desactivadas."; +"%@ web API access is disabled." = "El acceso a la API web de %@ está desactivado."; +"Disable %@ dashboard cookie usage." = "Desactiva el uso de cookies del panel de %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "El acceso al llavero está desactivado en Avanzado, así que la importación de cookies del navegador no está disponible."; +"Manually paste an %@ from a browser session." = "Pega manualmente un %@ de una sesión del navegador."; +"Paste a Cookie header captured from %@." = "Pega una cabecera Cookie capturada desde %@."; +"Paste a Cookie header from %@." = "Pega una cabecera Cookie de %@."; +"Paste a Cookie header or cURL capture from %@." = "Pega una cabecera Cookie o una captura cURL de %@."; +"Paste a Cookie header or full cURL capture from %@." = "Pega una cabecera Cookie o una captura cURL completa de %@."; +"Paste a Cookie or Authorization header from %@." = "Pega una cabecera Cookie o Authorization de %@."; +"Paste a full cookie header or the %@ value." = "Pega una cabecera de cookies completa o el valor %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Pega una cabecera Cookie o una captura cURL completa desde los ajustes de T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Pega la cabecera Cookie de una solicitud a admin.mistral.ai. Debe contener una cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Pega el Oasis-Token de una sesión iniciada en platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Pega el paquete JSON %@ de %@."; +"Paste the %@ value or a full Cookie header." = "Pega el valor %@ o una cabecera Cookie completa."; +"Personal account" = "Cuenta personal"; +"Project ID" = "ID de proyecto"; +"Re-auth" = "Reautenticar"; +"Re-authenticating…" = "Reautenticando…"; +"Refresh Session" = "Actualizar sesión"; +"Refresh organizations" = "Actualizar organizaciones"; +"Region" = "Región"; +"Reload" = "Recargar"; +"Reorder" = "Reordenar"; +"Secret access key" = "Clave de acceso secreta"; +"Series" = "Serie"; +"Service" = "Servicio"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Muestra u oculta créditos de Kiro, porcentaje o ambos junto al icono de la barra de menús."; +"Show usage for organizations you belong to. Personal account is always shown." = "Muestra el uso de las organizaciones a las que perteneces. La cuenta personal siempre se muestra."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Inicia sesión en cursor.com en el navegador y luego actualiza Cursor en CodexBar."; +"Simulated error text" = "Texto de error simulado"; +"StepFun platform account (phone number or email)." = "Cuenta de la plataforma StepFun (teléfono o correo)."; +"Stored in ~/.codexbar/config.json." = "Guardado en ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Guardado en ~/.codexbar/config.json. También se admite AZURE_OPENAI_API_KEY."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Guardado en ~/.codexbar/config.json. Para la API oficial de Kimi, usa Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Guardado en ~/.codexbar/config.json. Obtén tu clave API en la consola Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Guardado en ~/.codexbar/config.json. Obtén tu clave en los ajustes de Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Guardado en ~/.codexbar/config.json. Obtén tu clave en console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Guardado en ~/.codexbar/config.json. Obtén tu clave en elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Guardado en ~/.codexbar/config.json. Obtén tu clave en openrouter.ai/settings/keys y define allí un límite de gasto para activar el seguimiento de cuota."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Guardado en ~/.codexbar/config.json. En Warp, abre Settings > Platform > API Keys y crea una."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Guardado en ~/.codexbar/config.json. Las métricas requieren acceso a Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Guardado en ~/.codexbar/config.json. Se prefiere OPENAI_ADMIN_KEY; OPENAI_API_KEY también funciona."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Guardado en ~/.codexbar/config.json. Requiere una clave Anthropic Admin API."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Guardado en ~/.codexbar/config.json. Se usa para /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Guardado en ~/.codexbar/config.json. También puedes proporcionar CODEBUFF_API_KEY o dejar que CodexBar lea ~/.config/manicode/credentials.json (creado por `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Guardado en ~/.codexbar/config.json. También puedes proporcionar CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Guardado en ~/.codexbar/config.json. También puedes proporcionar KILO_API_KEY o ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Cookie de T3 Chat"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Esa cuenta ya no está disponible en CodexBar. Actualiza la lista de cuentas e inténtalo de nuevo."; +"The browser login did not complete in time. Try Antigravity login again." = "El inicio de sesión del navegador no terminó a tiempo. Intenta iniciar sesión en Antigravity de nuevo."; +"Timed out waiting for Cursor login. %@" = "Se agotó el tiempo esperando el inicio de sesión de Cursor. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Se agotó el tiempo esperando el inicio de sesión de Cursor. %@ Último error: %@"; +"Today requests" = "Solicitudes de hoy"; +"Total (30d): %@ credits" = "Total (30 d): %@ créditos"; +"Username" = "Usuario"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Usa usuario y contraseña para iniciar sesión y obtener un Oasis-Token automáticamente."; +"Uses username + password to login and obtain an %@ automatically." = "Usa usuario y contraseña para iniciar sesión y obtener un %@ automáticamente."; +"Utilization End" = "Fin de utilización"; +"Utilization Start" = "Inicio de utilización"; +"Verbosity" = "Detalle"; +"Windsurf session JSON bundle" = "Paquete JSON de sesión de Windsurf"; +"Workspace ID" = "ID de espacio de trabajo"; +"Your StepFun platform password. Used to login and obtain a session token." = "Tu contraseña de la plataforma StepFun. Se usa para iniciar sesión y obtener un token de sesión."; +"claude /login exited with status %d." = "claude /login salió con estado %d."; +"codex login exited with status %d." = "codex login salió con estado %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\no pega una captura cURL del panel de Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\no pega el valor de __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\no pega el valor del token kimi-auth"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\no pega solo el valor de session_id"; +"Clear" = "Borrar"; +"No matching providers" = "No hay proveedores coincidentes"; +"Search providers" = "Buscar proveedores"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 0b93ace5..bf843334 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -106,11 +106,10 @@ "Daily Routines" = "Rotinas diárias"; "Debug" = "Depuração"; "Default" = "Padrão"; -"Designs" = "Designs"; "Disable Keychain access" = "Desativar acesso ao Keychain"; "Disabled" = "Desativado"; -"Disconnected" = "Desconectado"; "Dismiss" = "Dispensar"; +"Disconnected" = "Desconectado"; "Display" = "Exibição"; "Display mode" = "Modo de exibição"; "Display reset times as absolute clock values instead of countdowns." = "Mostra horários de renovação como horas absolutas, em vez de contagens regressivas."; @@ -180,7 +179,6 @@ "No Codex accounts detected yet." = "Nenhuma conta Codex detectada ainda."; "No JetBrains IDE detected" = "Nenhuma IDE JetBrains detectada"; "No cost history data." = "Sem dados de histórico de custos."; -"No credits history data." = "Sem dados de histórico de créditos."; "No data available" = "Nenhum dado disponível"; "No data yet" = "Ainda sem dados"; "No enabled providers available for Overview." = "Nenhum provedor ativado disponível para Visão geral."; @@ -205,8 +203,8 @@ "Open Coding Plan" = "Abrir Coding Plan"; "Open Console" = "Abrir console"; "Open Dashboard" = "Abrir dashboard"; -"Open Menu Bar Settings" = "Abrir ajustes da barra de menus"; "Open Mistral Admin" = "Abrir Mistral Admin"; +"Open Menu Bar Settings" = "Abrir ajustes da barra de menus"; "Open Ollama Settings" = "Abrir ajustes do Ollama"; "Open Terminal" = "Abrir Terminal"; "Open Usage Page" = "Abrir página de uso"; @@ -399,6 +397,7 @@ "language_spanish" = "Espanhol"; "language_catalan" = "Catalão"; "language_chinese_simplified" = "Chinês simplificado"; +"language_chinese_traditional" = "Chinês tradicional"; "language_portuguese_brazilian" = "Português (Brasil)"; "start_at_login_title" = "Iniciar ao fazer login"; "start_at_login_subtitle" = "Abre o CodexBar automaticamente ao iniciar o Mac."; @@ -420,6 +419,13 @@ "quota_warning_session_capitalized" = "Sessão"; "quota_warning_weekly" = "semanal"; "quota_warning_weekly_capitalized" = "Semanal"; +"quota_warning_notification_title" = "%1$@: cota baixa (%2$@)"; +"quota_warning_notification_body" = "%1$@ restante. Você atingiu o limite de alerta de %2$d%% (%3$@)."; +"quota_warning_notification_body_with_account" = "Conta %1$@. %2$@ restante. Você atingiu o limite de alerta de %3$d%% (%4$@)."; +"session_depleted_notification_title" = "Sessão do %@ esgotada"; +"session_depleted_notification_body" = "0% restante. Avisaremos quando estiver disponível novamente."; +"session_restored_notification_title" = "Sessão do %@ restaurada"; +"session_restored_notification_body" = "A cota de sessão está disponível novamente."; "quota_warning_warn_at" = "Alertar em"; "quota_warning_global_threshold_subtitle" = "Percentuais restantes para as janelas de sessão e semanal, a menos que um provedor defina valores próprios."; "quota_warning_sound" = "Reproduzir som de notificação"; @@ -453,7 +459,10 @@ "remove" = "Remover"; "managed_login_already_running" = "Um login gerenciado do Codex já está em andamento. Aguarde terminar antes de adicionar ou reautenticar outra conta."; "managed_login_failed" = "O login gerenciado do Codex não foi concluído. Verifique se `codex --version` funciona no Terminal. Se o macOS bloqueou ou moveu `codex` para o Lixo, remova instalações duplicadas antigas, execute `npm install -g --include=optional @openai/codex@latest` e tente novamente."; +"codex_login_output" = "Saída do codex login:"; "managed_login_missing_email" = "O login do Codex foi concluído, mas nenhum e-mail da conta estava disponível. Tente novamente após confirmar que a conta está totalmente conectada."; +"login_success_notification_title" = "Login do %@ bem-sucedido"; +"login_success_notification_body" = "Você pode voltar ao app; a autenticação foi concluída."; "workspace_selection_cancelled" = "O CodexBar encontrou vários workspaces, mas nenhum foi selecionado."; "unsafe_managed_home" = "O CodexBar se recusou a modificar um caminho de diretório gerenciado inesperado: %@"; "menu_bar_metric_title" = "Métrica da barra de menus"; @@ -482,6 +491,8 @@ "show_usage_as_used_subtitle" = "As barras de progresso preenchem conforme você consome a cota (em vez de mostrar o restante)."; "show_quota_warning_markers_title" = "Mostrar marcadores de alerta de cota"; "show_quota_warning_markers_subtitle" = "Desenha marcas de limite nas barras de uso quando os alertas de cota estão configurados."; +"weekly_progress_work_days_title" = "Dias úteis no progresso semanal"; +"weekly_progress_work_days_subtitle" = "Desenha marcas de limite de dia nas barras de uso semanal."; "show_reset_time_as_clock_title" = "Mostrar renovação como horário"; "show_reset_time_as_clock_subtitle" = "Mostra horários de renovação como horas absolutas, em vez de contagens regressivas."; "show_provider_changelog_links_title" = "Mostrar links de changelog dos provedores"; @@ -632,3 +643,419 @@ /* Cost estimation */ "cost_header_estimated" = "Custo (estimado)"; "cost_estimate_hint" = "Estimado a partir de logs locais · pode diferir da sua fatura"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Nenhuma IDE JetBrains com AI Assistant detectada. Instale uma IDE JetBrains e ative o AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "Token de API do OpenRouter não configurado. Defina a variável de ambiente OPENROUTER_API_KEY ou configure em Ajustes."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "Token de API do z.ai não encontrado. Defina apiKey em ~/.codexbar/config.json ou Z_AI_API_KEY."; +"Missing DeepSeek API key." = "Chave de API do DeepSeek ausente."; +"%@ is unavailable in the current environment." = "%@ está indisponível no ambiente atual."; +"All Systems Operational" = "Todos os sistemas operacionais"; +"Last 30 days" = "Últimos 30 dias"; +"Last 30 days:" = "Últimos 30 dias:"; +"This month" = "Este mês"; +"Store multiple OpenAI API keys." = "Armazena várias chaves de API da OpenAI."; +"Admin API key" = "Chave de API admin"; +"Open billing" = "Abrir faturamento"; +"Google accounts" = "Contas Google"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Armazena várias contas Google OAuth do Antigravity para troca rápida."; +"Add Google Account" = "Adicionar conta Google"; +"Open Token Plan" = "Abrir Token Plan"; +"Text Generation" = "Geração de texto"; +"Text to Speech" = "Texto para fala"; +"Music Generation" = "Geração de música"; +"Image Generation" = "Geração de imagem"; +"No local data found" = "Nenhum dado local encontrado"; +"Credits unavailable; keep Codex running to refresh." = "Créditos indisponíveis; mantenha o Codex em execução para atualizar."; +"No available fetch strategy for minimax." = "Nenhuma estratégia de busca disponível para o minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Nenhuma sessão do Cursor encontrada. Faça login em cursor.com no Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX ou Edge Canary. Se você usa o Safari, conceda Acesso Total ao Disco ao CodexBar em Ajustes do Sistema ▸ Privacidade e Segurança. Você também pode entrar no Cursor pelo menu do CodexBar (Adicionar / trocar conta)."; +"No OpenCode session cookies found in browsers." = "Nenhum cookie de sessão do OpenCode encontrado nos navegadores."; +"No available fetch strategy for %@." = "Nenhuma estratégia de busca disponível para %@."; +"Today" = "Hoje"; +"Today tokens" = "Tokens de hoje"; +"30d cost" = "Custo 30 d"; +"30d tokens" = "Tokens 30 d"; +"Latest tokens" = "Tokens recentes"; +"Top model" = "Modelo principal"; +"Storage" = "Armazenamento"; +"Add Account..." = "Adicionar conta..."; +"Usage Dashboard" = "Dashboard de uso"; +"Status Page" = "Página de status"; +"Settings..." = "Ajustes..."; +"About CodexBar" = "Sobre o CodexBar"; +"Quit" = "Encerrar"; +"Last %d day" = "Último %d dia"; +"Last %d days" = "Últimos %d dias"; +"%@ tokens" = "%@ tokens"; +"Latest billing day" = "Último dia de cobrança"; +"Latest billing day (%@)" = "Último dia de cobrança (%@)"; +"%@ left" = "%@ restante"; +"Resets %@" = "Renova %@"; +"Resets in %@" = "Renova em %@"; +"Resets now" = "Renova agora"; +"Lasts until reset" = "Dura até a renovação"; +"Updated %@" = "Atualizado %@"; +"Updated %@h ago" = "Atualizado há %@h"; +"Updated %@m ago" = "Atualizado há %@m"; +"Updated just now" = "Atualizado agora mesmo"; +"Projected empty in %@" = "Esgotamento previsto em %@"; +"Runs out in %@" = "Esgota em %@"; +"Pace: %@" = "Ritmo: %@"; +"Pace: %@ · %@" = "Ritmo: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% de risco de esgotar"; +"%d%% in deficit" = "%d%% em déficit"; +"%d%% in reserve" = "%d%% em reserva"; +"usage_percent_suffix_left" = "restante"; +"usage_percent_suffix_used" = "usado"; +"Store multiple DeepSeek API keys." = "Armazena várias chaves de API do DeepSeek."; +"This week" = "Esta semana"; +"Week" = "Semana"; +"Month" = "Mês"; +"Models" = "Modelos"; +"24h tokens" = "Tokens 24 h"; +"Latest hour" = "Última hora"; +"Peak hour" = "Hora de pico"; +"Top method" = "Método principal"; +"30d cash" = "Dinheiro 30 d"; +"30d billing history from MiniMax web session" = "Histórico de cobrança de 30 dias da sessão web da MiniMax"; +"AWS Cost Explorer billing can lag." = "A cobrança do AWS Cost Explorer pode atrasar."; +"Rate limit: %d / %@" = "Limite de taxa: %d / %@"; +"Key remaining" = "Restante da chave"; +"No limit set for the API key" = "Nenhum limite configurado para a chave API"; +"API key limit unavailable right now" = "O limite da chave API está indisponível no momento"; +"This month: %@ tokens" = "Este mês: %@ tokens"; +"No utilization data yet." = "Ainda sem dados de uso."; +"No %@ utilization data yet." = "Ainda sem dados de uso de %@."; +"%@: %@%% used" = "%@: %@%% usado"; +"%dd" = "%dd"; +"today" = "hoje"; +"just now" = "agora mesmo"; +"On pace" = "No ritmo"; +"Runs out now" = "Esgota agora"; +"Projected empty now" = "Esgotamento previsto agora"; +"Switch Account..." = "Trocar conta..."; +"Update ready, restart now?" = "Atualização pronta, reiniciar agora?"; +"Daily" = "Diário"; +"Hourly Tokens" = "Tokens por hora"; +"No data" = "Sem dados"; +"No usage breakdown data available." = "Nenhum dado de detalhamento de uso disponível."; + +"Today: %@ · %@ tokens" = "Hoje: %@ · %@ tokens"; +"Today: %@" = "Hoje: %@"; +"Today: %@ tokens" = "Hoje: %@ tokens"; +"Last 30 days: %@ · %@ tokens" = "Últimos 30 dias: %@ · %@ tokens"; +"Last 30 days: %@" = "Últimos 30 dias: %@"; +"Est. total (30d): %@" = "Total est. (30d): %@"; +"Est. total (%@): %@" = "Total est. (%@): %@"; +"Hover a bar for details" = "Passe o mouse sobre uma barra para ver detalhes"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ tokens"; +"No providers selected for Overview." = "Nenhum provedor selecionado para a Visão geral."; +"No overview data available." = "Nenhum dado de visão geral disponível."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Automático usa primeiro a API local da IDE e depois o Google OAuth quando a IDE está fechada."; +"Login with Google" = "Entrar com o Google"; + +/* Popup panels */ +"No usage configured." = "Nenhum uso configurado."; +"Quota" = "Cota"; +"tokens" = "tokens"; +"requests" = "requisições"; +"Latest" = "Mais recente"; +"Monthly" = "Mensal"; +"Sonnet" = "Sonnet"; +"Overages" = "Excedentes"; +"Activity" = "Atividade"; +"Copied" = "Copiado"; +"Copy error" = "Erro ao copiar"; +"Copy path" = "Copiar caminho"; +"Extra usage spent" = "Gasto de uso extra"; +"Credits remaining" = "Créditos restantes"; +"Using CLI fallback" = "Usando fallback da CLI"; +"Balance updates in near-real time (up to 5 min lag)" = "O saldo atualiza quase em tempo real (até 5 min de atraso)"; +"Daily billing data finalizes at 07:00 UTC" = "Os dados diários de cobrança fecham às 07:00 UTC"; +"%@ of %@ credits left" = "Restam %@ de %@ créditos"; +"%@ of %@ bonus credits left" = "Restam %@ de %@ créditos bônus"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ restante)"; +"%@/%@ left" = "%@/%@ restante"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "Regenera %@"; +"used after next regen" = "usado após a próxima regeneração"; +"after next regen" = "após a próxima regeneração"; +"Near full" = "Quase cheio"; +"Full in ~1 regen" = "Cheio em ~1 regeneração"; +"Full in ~%.0f regens" = "Cheio em ~%.0f regenerações"; +"Overage usage" = "Uso excedente"; +"Overage cost" = "Custo excedente"; +"credits" = "créditos"; +"Zen balance" = "Saldo Zen"; +"API spend" = "Gasto de API"; +"Extra usage" = "Uso extra"; +"Quota usage" = "Uso da cota"; +"%.0f%% used" = "%.0f%% usado"; +"Usage history (today)" = "Histórico de uso (hoje)"; +"Usage history (%d days)" = "Histórico de uso (%d dias)"; +"%d percent remaining" = "%d%% restante"; +"Unknown" = "Desconhecido"; +"stale data" = "dados desatualizados"; +"No credits history data." = "Sem dados de histórico de créditos."; +"No credits history data available." = "Nenhum dado de histórico de créditos disponível."; +"Credits history chart" = "Gráfico de histórico de créditos"; +"%d days of credits data" = "%d dias de dados de créditos"; +"Usage breakdown chart" = "Gráfico de detalhamento de uso"; +"%d days of usage data across %d services" = "%d dias de dados de uso em %d serviços"; +"Cost history chart" = "Gráfico de histórico de custos"; +"%d days of cost data" = "%d dias de dados de custos"; +"Plan utilization chart" = "Gráfico de utilização do plano"; +"%d utilization samples" = "%d amostras de utilização"; +"Hourly Usage" = "Uso por hora"; +"Usage remaining" = "Uso restante"; +"Usage used" = "Uso usado"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "Chave de API verificada. O Ollama não expõe limites de cota do Cloud pela API."; +"Last 30 days: %@ tokens" = "Últimos 30 dias: %@ tokens"; +"7d spend" = "Gasto 7 d"; +"30d spend" = "Gasto 30 d"; +"Cache read" = "Leitura de cache"; +"Claude Admin API 30 day spend trend" = "Tendência de gasto de 30 dias da Claude Admin API"; +"OpenRouter API key spend trend" = "Tendência de gasto da chave API do OpenRouter"; +"z.ai hourly token trend" = "Tendência horária de tokens da z.ai"; +"MiniMax 30 day token usage trend" = "Tendência de uso de tokens de 30 dias da MiniMax"; +"Today cash" = "Dinheiro de hoje"; +"DeepSeek 30 day token usage trend" = "Tendência de uso de tokens de 30 dias da DeepSeek"; +"cache-hit input" = "entrada com acerto de cache"; +"cache-miss input" = "entrada sem acerto de cache"; +"output" = "saída"; +"Requests" = "Requisições"; +"Reported by OpenAI Admin API organization usage." = "Reportado pelo uso da organização na OpenAI Admin API."; +"Reported by Mistral billing usage." = "Reportado pelo uso de cobrança da Mistral."; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Adiciona contas via GitHub OAuth Device Flow no host selecionado."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Armazena cada conta Google conectada para troca rápida no Antigravity. Usa o OAuth do Antigravity.app quando disponível, ou ANTIGRAVITY_OAUTH_CLIENT_ID e ANTIGRAVITY_OAUTH_CLIENT_SECRET como substituição."; +"Manual cleanup: past sessions" = "Limpeza manual: sessões anteriores"; +"Clearing removes past resume, continue, and rewind history." = "A limpeza remove o histórico de retomar, continuar e voltar."; +"Manual cleanup: file checkpoints" = "Limpeza manual: checkpoints de arquivo"; +"Clearing removes checkpoint restore data for previous edits." = "A limpeza remove os dados de restauração de checkpoint de edições anteriores."; +"Manual cleanup: saved plans" = "Limpeza manual: planos salvos"; +"Clearing removes old plan-mode files." = "A limpeza remove arquivos antigos do modo de planejamento."; +"Manual cleanup: debug logs" = "Limpeza manual: logs de depuração"; +"Clearing removes past debug logs." = "A limpeza remove logs de depuração anteriores."; +"Manual cleanup: attachment cache" = "Limpeza manual: cache de anexos"; +"Clearing removes cached large pastes or attached images." = "A limpeza remove colagens grandes ou imagens anexadas em cache."; +"Manual cleanup: session metadata" = "Limpeza manual: metadados de sessão"; +"Clearing removes per-session environment metadata." = "A limpeza remove os metadados de ambiente por sessão."; +"Manual cleanup: shell snapshots" = "Limpeza manual: snapshots de shell"; +"Clearing removes leftover runtime shell snapshot files." = "A limpeza remove arquivos de snapshot de shell de runtime remanescentes."; +"Manual cleanup: legacy todos" = "Limpeza manual: tarefas legadas"; +"Clearing removes legacy per-session task lists." = "A limpeza remove listas de tarefas legadas por sessão."; +"Manual cleanup: sessions" = "Limpeza manual: sessões"; +"Clearing removes past Codex session history." = "A limpeza remove o histórico de sessões anteriores do Codex."; +"Manual cleanup: archived sessions" = "Limpeza manual: sessões arquivadas"; +"Clearing removes archived Codex session history." = "A limpeza remove o histórico de sessões arquivadas do Codex."; +"Manual cleanup: cache" = "Limpeza manual: cache"; +"Clearing removes provider-owned cached data." = "A limpeza remove dados em cache pertencentes ao provedor."; +"Manual cleanup: logs" = "Limpeza manual: logs"; +"Clearing removes local diagnostic logs." = "A limpeza remove logs de diagnóstico locais."; +"Manual cleanup: file history" = "Limpeza manual: histórico de arquivos"; +"Clearing removes local edit checkpoint history." = "A limpeza remove o histórico local de checkpoints de edição."; +"Manual cleanup: temporary data" = "Limpeza manual: dados temporários"; +"Clearing removes local temporary provider data." = "A limpeza remove dados temporários locais do provedor."; +"Total: %@" = "Total: %@"; +"%d more items" = "Mais %d itens"; +"Cleanup ideas" = "Ideias de limpeza"; +"%d unreadable item(s) skipped" = "%d item(ns) ilegível(is) ignorado(s)"; + +"API key limit" = "Limite da chave API"; +"Auth" = "Autenticação"; +"Auto" = "Automático"; +"Disabled — no recent data" = "Desativado — sem dados recentes"; +"Limits not available" = "Limites indisponíveis"; +"No usage yet" = "Ainda sem uso"; +"Not fetched yet" = "Ainda não buscado"; +"Refreshing" = "Atualizando"; +"Session" = "Sessão"; +"Source" = "Fonte"; +"State" = "Estado"; +"Unavailable" = "Indisponível"; +"Weekly" = "Semanal"; +"not detected" = "não detectado"; +"Estimated from local Codex logs for the selected account." = "Estimado a partir de logs locais do Codex para a conta selecionada."; +"minimax_usage_amount_format" = "Uso: %@ / %@"; +"minimax_used_percent_format" = "Usado %@"; +"minimax_service_text_generation" = "Geração de texto"; +"minimax_service_text_to_speech" = "Texto para fala"; +"minimax_service_music_generation" = "Geração de música"; +"minimax_service_image_generation" = "Geração de imagem"; +"minimax_service_lyrics_generation" = "Geração de letras"; +"minimax_service_coding_plan_vlm" = "VLM do Coding Plan"; +"minimax_service_coding_plan_search" = "Busca do Coding Plan"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ está aguardando permissão"; +"%@ requests" = "%@ solicitações"; +"%@: %@ credits" = "%@: %@ créditos"; +"30d requests" = "Solicitações de 30 dias"; +"4 days" = "4 dias"; +"5 days" = "5 dias"; +"7 days" = "7 dias"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "A chave de API verifica o acesso ao Ollama Cloud; os cookies ainda expõem limites de cota."; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "ID da chave de acesso da AWS. Também pode ser definido com AWS_ACCESS_KEY_ID."; +"AWS region. Can also be set with AWS_REGION." = "Região da AWS. Também pode ser definida com AWS_REGION."; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Chave secreta de acesso da AWS. Também pode ser definida com AWS_SECRET_ACCESS_KEY."; +"Access key ID" = "ID da chave de acesso"; +"Add Account" = "Adicionar conta"; +"Adding Account…" = "Adicionando conta…"; +"Antigravity login failed" = "Falha no login do Antigravity"; +"Antigravity login timed out" = "Tempo esgotado no login do Antigravity"; +"Auth source" = "Fonte de autenticação"; +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importa automaticamente cookies do Chrome do Xiaomi MiMo."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Importa automaticamente dados de sessão do Windsurf do localStorage do Chromium."; +"Automatic imports browser cookies from Bailian." = "Importa automaticamente cookies do navegador do Bailian."; +"Automatically imports browser cookies." = "Importa automaticamente cookies do navegador."; +"Automatically imports browser session cookies." = "Importa automaticamente cookies de sessão do navegador."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Nome do deployment do Azure OpenAI. AZURE_OPENAI_DEPLOYMENT_NAME também é aceito."; +"Azure OpenAI key" = "Chave do Azure OpenAI"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Endpoint do recurso Azure OpenAI. AZURE_OPENAI_ENDPOINT também é aceito."; +"Base URL" = "URL base"; +"Base URL for the LLM-API-Key-Proxy instance." = "URL base da instância LLM-API-Key-Proxy."; +"Browser cookies" = "Cookies do navegador"; +"Cap end" = "Fim do limite"; +"Cap start" = "Início do limite"; +"Capacity End" = "Fim da capacidade"; +"Capacity Start" = "Início da capacidade"; +"Changelog" = "Registro de alterações"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Escolha o host da API Moonshot/Kimi para contas internacionais ou da China continental."; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "O CodexBar não pode substituir uma conta do sistema conectada apenas com chave de API."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "O CodexBar não encontrou autenticação salva para essa conta. Reautentique e tente novamente."; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "O CodexBar não conseguiu ler o armazenamento de contas gerenciadas. Recupere o armazenamento antes de adicionar outra conta."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "O CodexBar não conseguiu ler a autenticação salva dessa conta. Reautentique e tente novamente."; +"CodexBar could not read the current system account on this Mac." = "O CodexBar não conseguiu ler a conta do sistema atual neste Mac."; +"CodexBar could not replace the live Codex auth on this Mac." = "O CodexBar não conseguiu substituir a autenticação ativa do Codex neste Mac."; +"CodexBar could not safely preserve the current system account before switching." = "O CodexBar não conseguiu preservar com segurança a conta do sistema atual antes da troca."; +"CodexBar could not save the current system account before switching." = "O CodexBar não conseguiu salvar a conta do sistema atual antes da troca."; +"CodexBar could not update managed account storage." = "O CodexBar não conseguiu atualizar o armazenamento de contas gerenciadas."; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "O CodexBar encontrou outra conta gerenciada que já usa a conta do sistema atual. Resolva a conta duplicada antes de trocar."; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS “%@” para descriptografar cookies do navegador e autenticar sua conta. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o token OAuth do Claude Code para buscar seu uso do Claude. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o cabeçalho Cookie do Amp para buscar uso. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o cabeçalho Cookie do Augment para buscar uso. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o cabeçalho Cookie do Claude para buscar uso web do Claude. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o cabeçalho Cookie do Cursor para buscar uso. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o cabeçalho Cookie do Factory para buscar uso. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o token do GitHub Copilot para buscar uso. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS a chave API do Kimi K2 para buscar uso. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o token de autenticação do Kimi para buscar uso. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o token API do MiniMax para buscar uso. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o cabeçalho Cookie do MiniMax para buscar uso. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o cabeçalho Cookie da OpenAI para buscar extras do painel Codex. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o cabeçalho Cookie do OpenCode para buscar uso. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS a chave API do Synthetic para buscar uso. Clique em OK para continuar."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "O CodexBar pedirá ao Chaves do macOS o token API do z.ai para buscar uso. Clique em OK para continuar."; +"Could not open Cursor login in your browser." = "Não foi possível abrir o login do Cursor no navegador."; +"Could not open browser for Antigravity" = "Não foi possível abrir o navegador para Antigravity"; +"Credits used" = "Créditos usados"; +"Day" = "Dia"; +"Deployment" = "Deployment"; +"Drag to reorder" = "Arraste para reordenar"; +"Endpoint" = "Endpoint"; +"Enterprise host" = "Host Enterprise"; +"Extra usage balance: %@" = "Saldo de uso extra: %@"; +"Keychain Access Required" = "Acesso ao Chaves necessário"; +"Kiro menu bar value" = "Valor do Kiro na barra de menu"; +"Label" = "Rótulo"; +"No organizations loaded. Click Refresh after setting your API key." = "Nenhuma organização carregada. Clique em Atualizar depois de definir sua chave API."; +"No output captured." = "Nenhuma saída capturada."; +"No system account" = "Sem conta do sistema"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "Abrir Augment (sair e entrar novamente)"; +"Open Codebuff Dashboard" = "Abrir painel do Codebuff"; +"Open Command Code Settings" = "Abrir configurações do Command Code"; +"Open Crof dashboard" = "Abrir painel do Crof"; +"Open Manus" = "Abrir Manus"; +"Open MiMo Balance" = "Abrir saldo do MiMo"; +"Open Moonshot Console" = "Abrir console do Moonshot"; +"Open Ollama API Keys" = "Abrir chaves API do Ollama"; +"Open StepFun Platform" = "Abrir plataforma StepFun"; +"Open T3 Chat Settings" = "Abrir configurações do T3 Chat"; +"Open Volcengine Ark Console" = "Abrir console Volcengine Ark"; +"Open legacy provider docs" = "Abrir docs do provedor legado"; +"Open projects" = "Abrir projetos"; +"Open this URL manually to continue login:\n\n%@" = "Abra esta URL manualmente para continuar o login:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "ID de organização opcional para contas vinculadas a várias organizações Anthropic."; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Opcional. Aplica-se à chave Admin API configurada; contas de token selecionadas não herdam OPENAI_PROJECT_ID."; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Opcional. Informe seu host GitHub Enterprise, por exemplo octocorp.ghe.com. Deixe em branco para github.com."; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Opcional. Deixe em branco para descobrir e agregar projetos visíveis à chave API."; +"Org ID (optional)" = "ID da org. (opcional)"; +"Organizations" = "Organizações"; +"Password" = "Senha"; +"%@ authentication is disabled." = "A autenticação de %@ está desativada."; +"%@ cookies are disabled." = "Os cookies de %@ estão desativados."; +"%@ web API access is disabled." = "O acesso à API web de %@ está desativado."; +"Disable %@ dashboard cookie usage." = "Desativar o uso de cookies do painel de %@."; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "O acesso ao Chaves está desativado em Avançado, então a importação de cookies do navegador está indisponível."; +"Manually paste an %@ from a browser session." = "Cole manualmente um %@ de uma sessão do navegador."; +"Paste a Cookie header captured from %@." = "Cole um cabeçalho Cookie capturado de %@."; +"Paste a Cookie header from %@." = "Cole um cabeçalho Cookie de %@."; +"Paste a Cookie header or cURL capture from %@." = "Cole um cabeçalho Cookie ou captura cURL de %@."; +"Paste a Cookie header or full cURL capture from %@." = "Cole um cabeçalho Cookie ou captura cURL completa de %@."; +"Paste a Cookie or Authorization header from %@." = "Cole um cabeçalho Cookie ou Authorization de %@."; +"Paste a full cookie header or the %@ value." = "Cole um cabeçalho de cookies completo ou o valor %@."; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Cole um cabeçalho Cookie ou captura cURL completa das configurações do T3 Chat."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Cole o cabeçalho Cookie de uma solicitação a admin.mistral.ai. Deve conter um cookie ory_session_*."; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Cole o Oasis-Token de uma sessão conectada em platform.stepfun.com."; +"Paste the %@ JSON bundle from %@." = "Cole o pacote JSON %@ de %@."; +"Paste the %@ value or a full Cookie header." = "Cole o valor %@ ou um cabeçalho Cookie completo."; +"Personal account" = "Conta pessoal"; +"Project ID" = "ID do projeto"; +"Re-auth" = "Reautenticar"; +"Re-authenticating…" = "Reautenticando…"; +"Refresh Session" = "Atualizar sessão"; +"Refresh organizations" = "Atualizar organizações"; +"Region" = "Região"; +"Reload" = "Recarregar"; +"Reorder" = "Reordenar"; +"Secret access key" = "Chave secreta de acesso"; +"Series" = "Série"; +"Service" = "Serviço"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Mostra ou oculta créditos Kiro, porcentagem ou ambos ao lado do ícone da barra de menu."; +"Show usage for organizations you belong to. Personal account is always shown." = "Mostra o uso das organizações às quais você pertence. A conta pessoal sempre é exibida."; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Entre em cursor.com no navegador e atualize Cursor no CodexBar."; +"Simulated error text" = "Texto de erro simulado"; +"StepFun platform account (phone number or email)." = "Conta da plataforma StepFun (telefone ou email)."; +"Stored in ~/.codexbar/config.json." = "Armazenado em ~/.codexbar/config.json."; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Armazenado em ~/.codexbar/config.json. AZURE_OPENAI_API_KEY também é aceito."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Armazenado em ~/.codexbar/config.json. Para a API oficial do Kimi, use Moonshot / Kimi API."; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Armazenado em ~/.codexbar/config.json. Obtenha sua chave API no console Volcengine Ark."; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Armazenado em ~/.codexbar/config.json. Obtenha sua chave nas configurações do Ollama."; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Armazenado em ~/.codexbar/config.json. Obtenha sua chave em console.deepgram.com."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Armazenado em ~/.codexbar/config.json. Obtenha sua chave em elevenlabs.io/app/settings/api-keys."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Armazenado em ~/.codexbar/config.json. Obtenha sua chave em openrouter.ai/settings/keys e defina um limite de gasto para ativar o rastreamento de cota."; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Armazenado em ~/.codexbar/config.json. No Warp, abra Settings > Platform > API Keys e crie uma."; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Armazenado em ~/.codexbar/config.json. As métricas exigem acesso ao Groq Enterprise Prometheus."; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Armazenado em ~/.codexbar/config.json. OPENAI_ADMIN_KEY é preferida; OPENAI_API_KEY ainda funciona."; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Armazenado em ~/.codexbar/config.json. Requer uma chave Anthropic Admin API."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Armazenado em ~/.codexbar/config.json. Usado para /v1/quota-stats."; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Armazenado em ~/.codexbar/config.json. Você também pode fornecer CODEBUFF_API_KEY ou permitir que o CodexBar leia ~/.config/manicode/credentials.json (criado por `codebuff login`)."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Armazenado em ~/.codexbar/config.json. Você também pode fornecer CROF_API_KEY."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Armazenado em ~/.codexbar/config.json. Você também pode fornecer KILO_API_KEY ou ~/.local/share/kilo/auth.json (kilo.access)."; +"T3 Chat cookie" = "Cookie do T3 Chat"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Essa conta não está mais disponível no CodexBar. Atualize a lista de contas e tente novamente."; +"The browser login did not complete in time. Try Antigravity login again." = "O login no navegador não foi concluído a tempo. Tente o login do Antigravity novamente."; +"Timed out waiting for Cursor login. %@" = "Tempo esgotado aguardando o login do Cursor. %@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Tempo esgotado aguardando o login do Cursor. %@ Último erro: %@"; +"Today requests" = "Solicitações de hoje"; +"Total (30d): %@ credits" = "Total (30 dias): %@ créditos"; +"Username" = "Nome de usuário"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Usa nome de usuário e senha para entrar e obter um Oasis-Token automaticamente."; +"Uses username + password to login and obtain an %@ automatically." = "Usa nome de usuário e senha para entrar e obter um %@ automaticamente."; +"Utilization End" = "Fim da utilização"; +"Utilization Start" = "Início da utilização"; +"Verbosity" = "Detalhamento"; +"Windsurf session JSON bundle" = "Pacote JSON de sessão do Windsurf"; +"Workspace ID" = "ID do workspace"; +"Your StepFun platform password. Used to login and obtain a session token." = "Sua senha da plataforma StepFun. Usada para entrar e obter um token de sessão."; +"claude /login exited with status %d." = "claude /login saiu com status %d."; +"codex login exited with status %d." = "codex login saiu com status %d."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\nou cole uma captura cURL do painel Abacus AI"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\nou cole o valor de __Secure-next-auth.session-token"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\nou cole o valor do token kimi-auth"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\nou cole apenas o valor de session_id"; +"Clear" = "Limpar"; +"No matching providers" = "Nenhum provedor correspondente"; +"Search providers" = "Buscar provedores"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings new file mode 100644 index 00000000..429d98e0 --- /dev/null +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -0,0 +1,1060 @@ +/* Swedish localization for CodexBar */ + +" providers" = " leverantörer"; +"(System)" = "(System)"; +"30d" = "30 d"; +"A managed Codex login is already running. Wait for it to finish before adding " = "En hanterad Codex-inloggning körs redan. Vänta tills den är klar innan du lägger till "; +"API key" = "API-nyckel"; +"API region" = "API-region"; +"API token" = "API-token"; +"API tokens" = "API-token"; +"About" = "Om"; +"Account" = "Konto"; +"Accounts" = "Konton"; +"Accounts subtitle" = "Kontounderrubrik"; +"Active" = "Aktiv"; +"Add" = "Lägg till"; +"Add Workspace" = "Lägg till arbetsyta"; +"Advanced" = "Avancerat"; +"All" = "Alla"; +"Always allow prompts" = "Tillåt alltid uppmaningar"; +"Animation pattern" = "Animationsmönster"; +"Antigravity login is managed in the app" = "Antigravity-inloggning hanteras i appen"; +"Applies only to the Security.framework OAuth keychain reader." = "Gäller bara OAuth-läsaren för Nyckelring via Security.framework."; +"Auto falls back to the next source if the preferred one fails." = "Auto går vidare till nästa källa om den föredragna misslyckas."; +"Auto uses API first, then falls back to CLI on auth failures." = "Auto använder API först och går sedan över till CLI vid autentiseringsfel."; +"Auto-detect" = "Identifiera automatiskt"; +"Auto-refresh is off; use the menu's Refresh command." = "Automatisk uppdatering är avstängd. Använd Uppdatera i menyn."; +"Auto-refresh: hourly · Timeout: 10m" = "Automatisk uppdatering: varje timme · Timeout: 10 min"; +"Automatic" = "Automatiskt"; +"Automatic imports browser cookies and WorkOS tokens." = "Importerar webbläsarcookies och WorkOS-token automatiskt."; +"Automatic imports browser cookies and local storage tokens." = "Importerar webbläsarcookies och token från lokal lagring automatiskt."; +"Automatic imports browser cookies for dashboard extras." = "Importerar webbläsarcookies automatiskt för extra instrumentpanelsdata."; +"Automatic imports browser cookies for the web API." = "Importerar webbläsarcookies automatiskt för webb-API:t."; +"Automatic imports browser cookies from Model Studio/Bailian." = "Importerar webbläsarcookies automatiskt från Model Studio/Bailian."; +"Automatic imports browser cookies from admin.mistral.ai." = "Importerar webbläsarcookies automatiskt från admin.mistral.ai."; +"Automatic imports browser cookies from opencode.ai." = "Importerar webbläsarcookies automatiskt från opencode.ai."; +"Automatic imports browser cookies or stored sessions." = "Importerar webbläsarcookies eller sparade sessioner automatiskt."; +"Automatic imports browser cookies." = "Importerar webbläsarcookies automatiskt."; +"Automatically imports browser session cookie." = "Importerar webbläsarens sessionscookie automatiskt."; +"Automatically opens CodexBar when you start your Mac." = "Öppnar CodexBar automatiskt när du startar din Mac."; +"Automation" = "Automatisering"; +"Average (\\(label1) + \\(label2))" = "Genomsnitt (\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "Genomsnitt (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "Undvik frågor från Nyckelring"; +"Balance" = "Saldo"; +"Battery Saver" = "Batterisparläge"; +"Bordered" = "Med ram"; +"Build" = "Bygge"; +"Built \\(buildTimestamp)" = "Byggd \\(buildTimestamp)"; +"Buy Credits..." = "Köp krediter..."; +"Buy Credits…" = "Köp krediter…"; +"CLI paths" = "CLI-sökvägar"; +"CLI sessions" = "CLI-sessioner"; +"Caches" = "Cachar"; +"Cancel" = "Avbryt"; +"Check for Updates…" = "Sök efter uppdateringar…"; +"Check for updates automatically" = "Sök efter uppdateringar automatiskt"; +"Check if you like your agents having some fun up there." = "Kontrollera om du vill att agenterna ska få leka lite där uppe."; +"Check provider status" = "Kontrollera leverantörsstatus"; +"Choose Codex workspace" = "Välj Codex-arbetsyta"; +"Choose the MiniMax host (global .io or China mainland .com)." = "Välj MiniMax-värd (global .io eller Fastlandskina .com)."; +"Choose up to " = "Välj upp till "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "Välj upp till \\(Self.maxOverviewProviders) leverantörer"; +"Choose up to \\(count) providers" = "Välj upp till \\(count) leverantörer"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "Välj vad som ska visas i menyraden (takt visar användning mot förväntat)."; +"Choose which Codex account CodexBar should follow." = "Välj vilket Codex-konto CodexBar ska följa."; +"Choose which window drives the menu bar percent." = "Välj vilket fönster som styr procenttalet i menyraden."; +"Chrome" = "Chrome"; +"Claude CLI not found" = "Claude CLI hittades inte"; +"Claude binary" = "Claude-binär"; +"Claude cookies" = "Claude-cookies"; +"Claude login failed" = "Claude-inloggning misslyckades"; +"Claude login timed out" = "Claude-inloggning tog för lång tid"; +"Close" = "Stäng"; +"Code review" = "Kodgranskning"; +"Codex CLI not found" = "Codex CLI hittades inte"; +"Codex account login already running" = "Codex-kontoinloggning körs redan"; +"Codex binary" = "Codex-binär"; +"Codex login failed" = "Codex-inloggning misslyckades"; +"Codex login timed out" = "Codex-inloggning tog för lång tid"; +"CodexBar Lifecycle Keepalive" = "CodexBar livscykel-keepalive"; +"CodexBar can't show its menu bar icon" = "CodexBar kan inte visa sin menyradsikon"; +"CodexBar could not read managed account storage. " = "CodexBar kunde inte läsa hanterad kontolagring. "; +"Configure…" = "Konfigurera…"; +"Connected" = "Ansluten"; +"Controls how much detail is logged." = "Styr hur detaljerad loggningen är."; +"Cookie header" = "Cookie-header"; +"Cookie source" = "Cookie-källa"; +"Cookie: ..." = "Cookie: ..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: \\u{2026}\\\n\\\neller klistra in en cURL-fångst från Abacus AI-instrumentpanelen"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie: \\u{2026}\\\n\\\neller klistra in värdet för __Secure-next-auth.session-token"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie: \\u{2026}\\\n\\\neller klistra in värdet för kimi-auth-token"; +"Cookie: …" = "Cookie: …"; +"CopilotDeviceFlow" = "CopilotDeviceFlow"; +"Cost" = "Kostnad"; +"Could not add Codex account" = "Kunde inte lägga till Codex-konto"; +"Could not open Terminal for Gemini" = "Kunde inte öppna Terminal för Gemini"; +"Could not start claude /login" = "Kunde inte starta claude /login"; +"Could not start codex login" = "Kunde inte starta codex login"; +"Could not switch system account" = "Kunde inte byta systemkonto"; +"Credits" = "Krediter"; +"Credits history" = "Kredithistorik"; +"Cursor login failed" = "Cursor-inloggning misslyckades"; +"Custom" = "Anpassat"; +"Custom Path" = "Anpassad sökväg"; +"Daily Routines" = "Dagliga rutiner"; +"Debug" = "Felsök"; +"Default" = "Standard"; +"Disable Keychain access" = "Inaktivera åtkomst till Nyckelring"; +"Disabled" = "Inaktiverad"; +"Dismiss" = "Stäng"; +"Disconnected" = "Frånkopplad"; +"Display" = "Visning"; +"Display mode" = "Visningsläge"; +"Display reset times as absolute clock values instead of countdowns." = "Visa återställningstider som klockslag i stället för nedräkningar."; +"Done" = "Klar"; +"Effective PATH" = "Effektiv PATH"; +"Email" = "E-post"; +"Enable Merge Icons to configure Overview tab providers." = "Aktivera Slå ihop ikoner för att konfigurera leverantörer på översiktsfliken."; +"Enable file logging" = "Aktivera filloggning"; +"Enabled" = "Aktiverad"; +"Error" = "Fel"; +"Error simulation" = "Felsimulering"; +"Expose troubleshooting tools in the Debug tab." = "Visa felsökningsverktyg på fliken Felsök."; +"Failed" = "Misslyckades"; +"False" = "Falskt"; +"Fetch strategy attempts" = "Försök med hämtningsstrategi"; +"Fetching" = "Hämtar"; +"Field" = "Fält"; +"Field subtitle" = "Fältunderrubrik"; +"Finish the current managed account change before switching the system account." = "Slutför den pågående hanterade kontoändringen innan du byter systemkonto."; +"Force animation on next refresh" = "Tvinga animation vid nästa uppdatering"; +"Gateway region" = "Gateway-region"; +"Gemini CLI not found" = "Gemini CLI hittades inte"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity och visar incidenter i ikonen och menyn."; +"General" = "Allmänt"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot-inloggning"; +"GitHub Login" = "GitHub-inloggning"; +"Hide details" = "Dölj detaljer"; +"Hide personal information" = "Dölj personuppgifter"; +"Historical tracking" = "Historisk spårning"; +"How often CodexBar polls providers in the background." = "Hur ofta CodexBar kontrollerar leverantörer i bakgrunden."; +"Inactive" = "Inaktiv"; +"Install CLI" = "Installera CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "Installera Claude CLI (npm i -g @anthropic-ai/claude-code) och försök igen."; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "Installera Codex CLI (npm i -g @openai/codex) och försök igen."; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "Installera Gemini CLI (npm i -g @google/gemini-cli) och försök igen."; +"JetBrains AI is ready" = "JetBrains AI är redo"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "Håll CLI-sessioner vid liv"; +"Keyboard shortcut" = "Kortkommando"; +"Keychain access" = "Åtkomst till Nyckelring"; +"Keychain prompt policy" = "Policy för frågor från Nyckelring"; +"Last \\(name) fetch failed:" = "Senaste hämtningen för \\(name) misslyckades:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "Senaste hämtningen för \\(self.store.metadata(for: self.provider).displayName) misslyckades:"; +"Last attempt" = "Senaste försök"; +"Link" = "Länk"; +"Loading animations" = "Laddningsanimationer"; +"Loading…" = "Läser in…"; +"Local" = "Lokal"; +"Logging" = "Loggning"; +"Login failed" = "Inloggningen misslyckades"; +"Login shell PATH (startup capture)" = "Inloggningsskalets PATH (fångad vid start)"; +"Login timed out" = "Inloggningen tog för lång tid"; +"MCP details" = "MCP-detaljer"; +"Managed Codex accounts unavailable" = "Hanterade Codex-konton är inte tillgängliga"; +"Managed account storage is unreadable. Live account access is still available, " = "Hanterad kontolagring går inte att läsa. Direkt kontoåtkomst är fortfarande tillgänglig, "; +"Manual" = "Manuellt"; +"May your tokens never run out—keep agent limits in view." = "Må dina token aldrig ta slut – håll agentgränserna synliga."; +"Menu bar" = "Menyrad"; +"Menu bar auto-shows the provider closest to its rate limit." = "Menyraden visar automatiskt leverantören som ligger närmast sin gräns."; +"Menu bar metric" = "Menyradsmått"; +"Menu bar shows percent" = "Menyraden visar procent"; +"Menu content" = "Menyinnehåll"; +"Merge Icons" = "Slå ihop ikoner"; +"Never prompt" = "Fråga aldrig"; +"No" = "Nej"; +"No Codex accounts detected yet." = "Inga Codex-konton har hittats än."; +"No JetBrains IDE detected" = "Ingen JetBrains IDE hittades"; +"No cost history data." = "Ingen kostnadshistorik."; +"No credits history data." = "Ingen kredithistorik."; +"No data available" = "Inga data tillgängliga"; +"No data yet" = "Inga data än"; +"No enabled providers available for Overview." = "Inga aktiverade leverantörer är tillgängliga för översikten."; +"No providers selected" = "Inga leverantörer valda"; +"No token accounts yet." = "Inga tokenkonton än."; +"No usage breakdown data." = "Ingen användningsuppdelning."; +"None" = "Ingen"; +"Notifications" = "Aviseringar"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "Aviserar när femtimmarssessionens kvot når 0 % och när den blir "; +"OK" = "OK"; +"Obscure email addresses in the menu bar and menu UI." = "Maskera e-postadresser i menyraden och menygränssnittet."; +"Off" = "Av"; +"Offline" = "Offline"; +"On" = "På"; +"Online" = "Online"; +"Only on user action" = "Bara vid användaråtgärd"; +"Open" = "Öppna"; +"Open API Keys" = "Öppna API-nycklar"; +"Open Amp Settings" = "Öppna Amp-inställningar"; +"Open Antigravity to sign in, then refresh CodexBar." = "Öppna Antigravity för att logga in och uppdatera sedan CodexBar."; +"Open Browser" = "Öppna webbläsare"; +"Open Coding Plan" = "Öppna Coding Plan"; +"Open Console" = "Öppna konsol"; +"Open Dashboard" = "Öppna instrumentpanel"; +"Open Mistral Admin" = "Öppna Mistral Admin"; +"Open Menu Bar Settings" = "Öppna inställningar för menyraden"; +"Open Ollama Settings" = "Öppna Ollama-inställningar"; +"Open Terminal" = "Öppna Terminal"; +"Open Usage Page" = "Öppna användningssida"; +"Open Warp API Key Guide" = "Öppna guide för Warp API-nyckel"; +"Open menu" = "Öppna meny"; +"Open token file" = "Öppna tokenfil"; +"OpenAI cookies" = "OpenAI-cookies"; +"OpenAI web extras" = "OpenAI-webbtillägg"; +"Option A" = "Alternativ A"; +"Option B" = "Alternativ B"; +"Optional override if workspace lookup fails." = "Valfri ersättning om sökning efter arbetsyta misslyckas."; +"Options" = "Alternativ"; +"Override auto-detection with a custom IDE base path" = "Ersätt automatisk identifiering med en anpassad bas-sökväg till IDE:n"; +"Overview" = "Översikt"; +"Overview rows always follow provider order." = "Översiktsrader följer alltid leverantörsordningen."; +"Overview tab providers" = "Leverantörer på översiktsfliken"; +"Paste API key…" = "Klistra in API-nyckel…"; +"Paste API token…" = "Klistra in API-token…"; +"Paste key…" = "Klistra in nyckel…"; +"Paste sessionKey or OAuth token…" = "Klistra in sessionKey eller OAuth-token…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "Klistra in Cookie-headern från en förfrågan till admin.mistral.ai. "; +"Paste token…" = "Klistra in token…"; +"Personal" = "Personligt"; +"Picker" = "Väljare"; +"Picker subtitle" = "Väljarunderrubrik"; +"Placeholder" = "Platshållare"; +"Plan" = "Plan"; +"Play full-screen confetti when weekly usage resets." = "Spela konfetti i helskärm när veckoförbrukningen återställs."; +"Polls OpenAI/Claude status pages and Google Workspace for " = "Kontrollerar OpenAI/Claude-statussidor och Google Workspace för "; +"Prevents any Keychain access while enabled." = "Förhindrar all åtkomst till Nyckelring när det är aktiverat."; +"Primary (API key limit)" = "Primär (API-nyckelgräns)"; +"Primary (\\(label))" = "Primär (\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "Primär (\\(metadata.sessionLabel))"; +"Probe logs" = "Probloggar"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "Förloppsstaplar fylls när du förbrukar kvot i stället för att visa återstående."; +"Provider" = "Leverantör"; +"Providers" = "Leverantörer"; +"Quit CodexBar" = "Avsluta CodexBar"; +"Random (default)" = "Slumpmässig (standard)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "Läser lokala användningsloggar. Visar idag och valt historikfönster i menyn."; +"Refresh" = "Uppdatera"; +"Refresh cadence" = "Uppdateringsintervall"; +"Remote" = "Fjärr"; +"Remove" = "Ta bort"; +"Remove Codex account?" = "Ta bort Codex-konto?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "Ta bort \\(account.email) från CodexBar? Dess hanterade Codex-hem tas bort."; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "Ta bort \\(email) från CodexBar? Dess hanterade Codex-hem tas bort."; +"Remove selected account" = "Ta bort valt konto"; +"Replace critter bars with provider branding icons and a percentage." = "Ersätt figurstaplar med leverantörsikoner och ett procenttal."; +"Replay selected animation" = "Spela vald animation igen"; +"Requires authentication via GitHub Device Flow." = "Kräver autentisering via GitHub Device Flow."; +"Resets: \\(reset)" = "Återställs: \\(reset)"; +"Rolling five-hour limit" = "Rullande femtimmarsgräns"; +"Search hourly" = "Sökning per timme"; +"Secondary (\\(label))" = "Sekundär (\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "Sekundär (\\(metadata.weeklyLabel))"; +"Select a provider" = "Välj en leverantör"; +"Select the IDE to monitor" = "Välj IDE att övervaka"; +"Session quota notifications" = "Aviseringar för sessionskvot"; +"Session tokens" = "Sessionstoken"; +"Settings" = "Inställningar"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "Visa avsnitt för Codex-krediter och Claude Extra-användning i menyn."; +"Show Debug Settings" = "Visa felsökningsinställningar"; +"Show all token accounts" = "Visa alla tokenkonton"; +"Show cost summary" = "Visa kostnadssammanfattning"; +"Show credits + extra usage" = "Visa krediter och extra användning"; +"Show details" = "Visa detaljer"; +"Show most-used provider" = "Visa mest använda leverantör"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "Visa leverantörsikoner i växlaren (annars visas en veckoförloppslinje)."; +"Show reset time as clock" = "Visa återställningstid som klockslag"; +"Show usage as used" = "Visa användning som förbrukad"; +"Sign in via button below" = "Logga in med knappen nedan"; +"Skip teardown between probes (debug-only)." = "Hoppa över nedstängning mellan prober (endast felsökning)."; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "Stapla tokenkonton i menyn (annars visas en kontoväxlare)."; +"Start at Login" = "Starta vid inloggning"; +"Status" = "Status"; +"Store Claude sessionKey cookies or OAuth access tokens." = "Spara Claude-sessionKey-cookies eller OAuth-åtkomsttoken."; +"Store multiple Abacus AI Cookie headers." = "Spara flera Cookie-headers för Abacus AI."; +"Store multiple Augment Cookie headers." = "Spara flera Cookie-headers för Augment."; +"Store multiple Cursor Cookie headers." = "Spara flera Cookie-headers för Cursor."; +"Store multiple Factory Cookie headers." = "Spara flera Cookie-headers för Factory."; +"Store multiple MiniMax Cookie headers." = "Spara flera Cookie-headers för MiniMax."; +"Store multiple Mistral Cookie headers." = "Spara flera Cookie-headers för Mistral."; +"Store multiple Ollama Cookie headers." = "Spara flera Cookie-headers för Ollama."; +"Store multiple OpenCode Cookie headers." = "Spara flera Cookie-headers för OpenCode."; +"Store multiple OpenCode Go Cookie headers." = "Spara flera Cookie-headers för OpenCode Go."; +"Stored in the CodexBar config file." = "Sparas i CodexBars konfigurationsfil."; +"Stored in ~/.codexbar/config.json. " = "Sparas i ~/.codexbar/config.json. "; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "Sparas i ~/.codexbar/config.json. Skapa en på kimi-k2.ai."; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "Sparas i ~/.codexbar/config.json. Klistra in nyckeln från Synthetic-instrumentpanelen."; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "Sparas i ~/.codexbar/config.json. Klistra in din Coding Plan-API-nyckel från Model Studio."; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "Sparas i ~/.codexbar/config.json. Klistra in din MiniMax-API-nyckel."; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "Sparas i ~/.codexbar/config.json. Du kan också ange KILO_API_KEY eller "; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "Sparar lokal Codex-användningshistorik (8 veckor) för att anpassa taktprognoser."; +"Subscription Utilization" = "Abonnemangsutnyttjande"; +"Surprise me" = "Överraska mig"; +"Switcher shows icons" = "Växlaren visar ikoner"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "Symlänka CodexBarCLI till /usr/local/bin och /opt/homebrew/bin som codexbar."; +"System" = "System"; +"Temporarily shows the loading animation after the next refresh." = "Visar tillfälligt laddningsanimationen efter nästa uppdatering."; +"Tertiary (\\(label))" = "Tertiär (\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "Tertiär (\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "Standardkontot för Codex på den här Macen."; +"Toggle" = "Växla"; +"Toggle subtitle" = "Växlingsunderrubrik"; +"Token" = "Token"; +"Trigger the menu bar menu from anywhere." = "Öppna menyradsmenyn var du än är."; +"True" = "Sant"; +"Twitter" = "Twitter"; +"Unsupported" = "Stöds inte"; +"Update Channel" = "Uppdateringskanal"; +"Updated" = "Uppdaterad"; +"Updates unavailable in this build." = "Uppdateringar är inte tillgängliga i det här bygget."; +"Usage" = "Användning"; +"Usage breakdown" = "Användningsuppdelning"; +"Usage history (30 days)" = "Användningshistorik"; +"Usage source" = "Användningskälla"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "Använd BigModel för slutpunkterna i Fastlandskina (open.bigmodel.cn)."; +"Use a single menu bar icon with a provider switcher." = "Använd en enda menyradsikon med leverantörsväxlare."; +"Use international or China mainland console gateways for quota fetches." = "Använd internationella gatewayar eller gatewayar för Fastlandskina vid kvothämtning."; +"Version" = "Version"; +"Version \\(self.versionString)" = "Version \\(self.versionString)"; +"Version \\(version)" = "Version \\(version)"; +"Version \\(versionString)" = "Version \\(versionString)"; +"Vertex AI Login" = "Vertex AI-inloggning"; +"Wait for the current managed Codex login to finish before adding another account." = "Vänta tills den pågående hanterade Codex-inloggningen är klar innan du lägger till ett konto till."; +"Waiting for Authentication..." = "Väntar på autentisering..."; +"Website" = "Webbplats"; +"Weekly limit confetti" = "Veckogränskonfetti"; +"Weekly token limit" = "Veckogräns för token"; +"Weekly usage" = "Veckoanvändning"; +"Weekly usage unavailable for this account." = "Veckoanvändning är inte tillgänglig för det här kontot."; +"Window: \\(window)" = "Fönster: \\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "Skriv loggar till \\(self.fileLogPath) för felsökning."; +"Yes" = "Ja"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode): \\(usage)"; +"\\(name): \\(truncated)" = "\\(name): \\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name): \\(updated) · 30 d \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name): hämtar…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name): senaste försök \\(when)"; +"\\(name): no data yet" = "\\(name): inga data än"; +"\\(name): unsupported" = "\\(name): stöds inte"; +"all browsers" = "alla webbläsare"; +"available again." = "tillgänglig igen."; +"built_format" = "Byggd %@"; +"copilot_complete_in_browser" = "Slutför inloggningen i webbläsaren."; +"copilot_device_code" = "Enhetskoden kopierades till urklipp: %1$@\n\nVerifiera på: %2$@"; +"copilot_device_code_copied" = "Enhetskoden kopierades."; +"copilot_verify_at" = "Verifiera på %@"; +"copilot_waiting_text" = "Slutför inloggningen i webbläsaren.\nDet här fönstret stängs automatiskt när inloggningen är klar."; +"copilot_window_closes_auto" = "Det här fönstret stängs automatiskt när inloggningen är klar."; +"cost_status_error" = "%1$@: %2$@"; +"cost_status_fetching" = "%1$@: hämtar… %2$@"; +"cost_status_last_attempt" = "%1$@: senaste försök %2$@"; +"cost_status_no_data" = "%@: inga data än"; +"cost_status_snapshot" = "%1$@: %2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@: stöds inte"; +"credits_remaining" = "Krediter: %@"; +"cursor_on_demand" = "Vid behov: %@"; +"cursor_on_demand_with_limit" = "Vid behov: %1$@ / %2$@"; +"extra_usage_format" = "Extra användning: %1$@ / %2$@"; +"jetbrains_detected_generate" = "Hittade: %@. Använd AI-assistenten en gång för att skapa kvotdata och uppdatera sedan CodexBar."; +"jetbrains_detected_select" = "Hittade: %@. Välj önskad IDE i Inställningar och uppdatera sedan CodexBar."; +"last_fetch_failed_with_provider" = "Senaste hämtningen för %@ misslyckades:"; +"last_spend" = "Senaste utgift: %@"; +"mcp_model_usage" = "%1$@: %2$@"; +"mcp_resets" = "Återställs: %@"; +"mcp_window" = "Fönster: %@"; +"metric_average" = "Genomsnitt (%1$@ + %2$@)"; +"metric_primary" = "Primär (%@)"; +"metric_secondary" = "Sekundär (%@)"; +"metric_tertiary" = "Tertiär (%@)"; +"multiple_workspaces_found" = "CodexBar hittade flera arbetsytor för %@. Välj arbetsytan som ska läggas till."; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "Välj upp till %@ leverantörer"; +"remove_account_message" = "Ta bort %@ från CodexBar? Dess hanterade Codex-hem tas bort."; +"version_format" = "Version %@"; +"vertex_ai_login_instructions" = "Autentisera med Google Cloud för att följa Vertex AI-användning.\n\n1. Öppna Terminal\n2. Kör: gcloud auth application-default login\n3. Följ anvisningarna i webbläsaren för att logga in\n4. Ange ditt projekt: gcloud config set project PROJECT_ID\n\nÖppna Terminal nu?"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "workspaceID är angivet, men bara opencode, opencodego och deepgram stöder workspaceID."; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger. MIT-licens."; + +/* General Pane */ +"section_system" = "System"; +"section_usage" = "Användning"; +"section_automation" = "Automatisering"; +"language_title" = "Språk"; +"language_subtitle" = "Byt visningsspråk. Appen behöver startas om för att ändringen ska slå igenom helt."; +"language_system" = "System"; +"language_english" = "English"; +"language_spanish" = "Español"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"language_swedish" = "Svenska"; +"start_at_login_title" = "Starta vid inloggning"; +"start_at_login_subtitle" = "Öppnar CodexBar automatiskt när du startar din Mac."; +"show_cost_summary" = "Visa kostnadssammanfattning"; +"show_cost_summary_subtitle" = "Läser lokala användningsloggar. Visar idag och valt historikfönster i menyn."; +"cost_history_days_title" = "Historikfönster: %d dagar"; +"cost_auto_refresh_info" = "Automatisk uppdatering: varje timme · Timeout: 10 min"; +"refresh_cadence_title" = "Uppdateringsintervall"; +"refresh_cadence_subtitle" = "Hur ofta CodexBar kontrollerar leverantörer i bakgrunden."; +"manual_refresh_hint" = "Automatisk uppdatering är avstängd. Använd Uppdatera i menyn."; +"check_provider_status_title" = "Kontrollera leverantörsstatus"; +"check_provider_status_subtitle" = "Kontrollerar OpenAI/Claude-statussidor och Google Workspace för Gemini/Antigravity och visar incidenter i ikonen och menyn."; +"session_quota_notifications_title" = "Aviseringar för sessionskvot"; +"session_quota_notifications_subtitle" = "Aviserar när femtimmarssessionens kvot når 0 % och när den blir tillgänglig igen."; +"quota_warning_notifications_title" = "Kvotvarningsaviseringar"; +"quota_warning_notifications_subtitle" = "Varnar när återstående sessions- eller veckokvot passerar inställda trösklar."; +"quota_warnings_title" = "Kvotvarningar"; +"quota_warning_session" = "session"; +"quota_warning_session_capitalized" = "Session"; +"quota_warning_weekly" = "veckokvot"; +"quota_warning_weekly_capitalized" = "Veckokvot"; +"quota_warning_notification_title" = "%1$@ %2$@-kvot låg"; +"quota_warning_notification_body" = "%1$@ kvar. Din varningströskel på %2$d %% för %3$@ har nåtts."; +"quota_warning_notification_body_with_account" = "Konto %1$@. %2$@ kvar. Din varningströskel på %3$d %% för %4$@ har nåtts."; +"session_depleted_notification_title" = "%@-sessionen är slut"; +"session_depleted_notification_body" = "0 % kvar. Du får en avisering när den är tillgänglig igen."; +"session_restored_notification_title" = "%@-sessionen är återställd"; +"session_restored_notification_body" = "Sessionskvoten är tillgänglig igen."; +"quota_warning_warn_at" = "Varna vid"; +"quota_warning_global_threshold_subtitle" = "Återstående procent för sessions- och veckofönster, om inte en leverantör åsidosätter dem."; +"quota_warning_sound" = "Spela aviseringsljud"; +"quota_warning_provider_inherits" = "Använder de globala kvotvarningsinställningarna om inte ett fönster anpassas här."; +"quota_warning_customize_thresholds" = "Anpassa trösklar för %@"; +"quota_warning_enable_warnings" = "Aktivera varningar för %@"; +"quota_warning_window_warn_at" = "Varna vid för %@"; +"quota_warning_off" = "Av"; +"quota_warning_inherited" = "Ärvd: %@"; +"quota_warning_depleted_only" = "bara slut"; +"quota_warning_upper" = "Övre"; +"quota_warning_lower" = "Nedre"; +"apply" = "Tillämpa"; +"quit_app" = "Avsluta CodexBar"; + +/* Tab titles */ +"tab_general" = "Allmänt"; +"tab_providers" = "Leverantörer"; +"tab_display" = "Visning"; +"tab_advanced" = "Avancerat"; +"tab_about" = "Om"; +"tab_debug" = "Felsök"; + +/* Providers Pane */ +"select_a_provider" = "Välj en leverantör"; +"cancel" = "Avbryt"; +"last_fetch_failed" = "senaste hämtningen misslyckades"; +"usage_not_fetched_yet" = "användning har inte hämtats än"; +"managed_account_storage_unreadable" = "Hanterad kontolagring går inte att läsa. Direkt kontoåtkomst är fortfarande tillgänglig, men hanterade åtgärder för att lägga till, autentisera om och ta bort är inaktiverade tills lagringen kan återställas."; +"remove_codex_account_title" = "Ta bort Codex-konto?"; +"remove" = "Ta bort"; +"managed_login_already_running" = "En hanterad Codex-inloggning körs redan. Vänta tills den är klar innan du lägger till eller autentiserar om ett annat konto."; +"managed_login_failed" = "Den hanterade Codex-inloggningen slutfördes inte. Kontrollera att `codex --version` fungerar i Terminal. Om macOS blockerade eller flyttade `codex` till papperskorgen tar du bort gamla dubblettinstallationer, kör `npm install -g --include=optional @openai/codex@latest` och försöker igen."; +"managed_login_missing_email" = "Codex-inloggningen slutfördes, men ingen e-postadress för kontot var tillgänglig. Försök igen när du har kontrollerat att kontot är helt inloggat."; +"login_success_notification_title" = "%@-inloggning lyckades"; +"login_success_notification_body" = "Du kan återgå till appen. Autentiseringen är klar."; +"workspace_selection_cancelled" = "CodexBar hittade flera arbetsytor, men ingen arbetsyta valdes."; +"unsafe_managed_home" = "CodexBar vägrade ändra en oväntad hanterad hem-sökväg: %@"; +"menu_bar_metric_title" = "Menyradsmått"; +"menu_bar_metric_subtitle" = "Välj vilket fönster som styr procenttalet i menyraden."; +"menu_bar_metric_subtitle_deepseek" = "Visar DeepSeek-saldot i menyraden."; +"menu_bar_metric_subtitle_moonshot" = "Visar saldot för Moonshot/Kimi API i menyraden."; +"menu_bar_metric_subtitle_mistral" = "Visar den aktuella månadens Mistral API-utgift i menyraden."; +"menu_bar_metric_subtitle_kimik2" = "Visar Kimi K2-API-nyckelkrediter i menyraden."; +"automatic" = "Automatiskt"; +"primary_api_key_limit" = "Primär (API-nyckelgräns)"; + +/* Display Pane */ +"section_menu_bar" = "Menyrad"; +"merge_icons_title" = "Slå ihop ikoner"; +"merge_icons_subtitle" = "Använd en enda menyradsikon med leverantörsväxlare."; +"switcher_shows_icons_title" = "Växlaren visar ikoner"; +"switcher_shows_icons_subtitle" = "Visa leverantörsikoner i växlaren (annars visas en veckoförloppslinje)."; +"show_most_used_provider_title" = "Visa mest använda leverantör"; +"show_most_used_provider_subtitle" = "Menyraden visar automatiskt leverantören som ligger närmast sin gräns."; +"menu_bar_shows_percent_title" = "Menyraden visar procent"; +"menu_bar_shows_percent_subtitle" = "Ersätt figurstaplar med leverantörsikoner och ett procenttal."; +"display_mode_title" = "Visningsläge"; +"display_mode_subtitle" = "Välj vad som ska visas i menyraden (takt visar användning mot förväntat)."; +"section_menu_content" = "Menyinnehåll"; +"show_usage_as_used_title" = "Visa användning som förbrukad"; +"show_usage_as_used_subtitle" = "Förloppsstaplar fylls när du förbrukar kvot i stället för att visa återstående."; +"show_quota_warning_markers_title" = "Visa kvotvarningsmarkörer"; +"show_quota_warning_markers_subtitle" = "Rita tröskelmarkeringar på användningsstaplar när kvotvarningar är konfigurerade."; +"weekly_progress_work_days_title" = "Arbetsdagar i veckoförlopp"; +"weekly_progress_work_days_subtitle" = "Rita dagsgränsmarkeringar på veckostaplar."; +"show_reset_time_as_clock_title" = "Visa återställningstid som klockslag"; +"show_reset_time_as_clock_subtitle" = "Visa återställningstider som klockslag i stället för nedräkningar."; +"show_provider_changelog_links_title" = "Visa länkar till leverantörers ändringsloggar"; +"show_provider_changelog_links_subtitle" = "Lägger till länkar till utgåvekommentarer för stödda CLI-baserade leverantörer i menyn."; +"show_credits_extra_usage_title" = "Visa krediter och extra användning"; +"show_credits_extra_usage_subtitle" = "Visa avsnitt för Codex-krediter och Claude Extra-användning i menyn."; +"show_all_token_accounts_title" = "Visa alla tokenkonton"; +"show_all_token_accounts_subtitle" = "Stapla tokenkonton i menyn (annars visas en kontoväxlare)."; +"multi_account_layout_title" = "Layout för flera konton"; +"multi_account_layout_subtitle" = "Välj segmenterad kontoväxling eller staplade kontokort."; +"multi_account_layout_segmented" = "Segmenterad"; +"multi_account_layout_stacked" = "Staplad"; +"overview_tab_providers_title" = "Leverantörer på översiktsfliken"; +"configure" = "Konfigurera…"; +"overview_enable_merge_icons_hint" = "Aktivera Slå ihop ikoner för att konfigurera leverantörer på översiktsfliken."; +"overview_no_providers_hint" = "Inga aktiverade leverantörer är tillgängliga för översikten."; +"overview_rows_follow_order" = "Översiktsrader följer alltid leverantörsordningen."; +"overview_no_providers_selected" = "Inga leverantörer valda"; + +/* Advanced Pane */ +"section_keyboard_shortcut" = "Kortkommando"; +"open_menu_shortcut_title" = "Öppna meny"; +"open_menu_shortcut_subtitle" = "Öppna menyradsmenyn var du än är."; +"install_cli" = "Installera CLI"; +"install_cli_subtitle" = "Symlänka CodexBarCLI till /usr/local/bin och /opt/homebrew/bin som codexbar."; +"cli_not_found" = "CodexBarCLI hittades inte i apppaketet."; +"no_writable_bin_dirs" = "Inga skrivbara bin-kataloger hittades."; +"show_debug_settings_title" = "Visa felsökningsinställningar"; +"show_debug_settings_subtitle" = "Visa felsökningsverktyg på fliken Felsök."; +"surprise_me_title" = "Överraska mig"; +"surprise_me_subtitle" = "Kontrollera om du vill att agenterna ska få leka lite där uppe."; +"weekly_limit_confetti_title" = "Veckogränskonfetti"; +"weekly_limit_confetti_subtitle" = "Spela konfetti i helskärm när veckoförbrukningen återställs."; +"hide_personal_info_title" = "Dölj personuppgifter"; +"hide_personal_info_subtitle" = "Maskera e-postadresser i menyraden och menygränssnittet."; +"show_provider_storage_usage_title" = "Visa leverantörers lagringsanvändning"; +"show_provider_storage_usage_subtitle" = "Visa lokal diskanvändning i menyer. Söker igenom kända leverantörsägda sökvägar i bakgrunden."; +"section_keychain_access" = "Åtkomst till Nyckelring"; +"keychain_access_caption" = "Inaktivera all läsning och skrivning i Nyckelring. Använd detta om macOS fortsätter fråga om 'Chrome/Brave/Edge Safe Storage' även efter att du klickat på Tillåt alltid. Import av webbläsarcookies är inte tillgänglig när detta är aktiverat. Klistra in Cookie-headers manuellt under Leverantörer. Claude/Codex OAuth via CLI fungerar fortfarande."; +"disable_keychain_access_title" = "Inaktivera åtkomst till Nyckelring"; +"disable_keychain_access_subtitle" = "Förhindrar all åtkomst till Nyckelring när det är aktiverat."; + +/* About Pane */ +"about_tagline" = "Må dina token aldrig ta slut – håll agentgränserna synliga."; +"link_github" = "GitHub"; +"link_website" = "Webbplats"; +"link_twitter" = "Twitter"; +"link_email" = "E-post"; +"check_updates_auto" = "Sök efter uppdateringar automatiskt"; +"update_channel" = "Uppdateringskanal"; +"check_for_updates" = "Sök efter uppdateringar…"; +"updates_unavailable" = "Uppdateringar är inte tillgängliga i det här bygget."; +"copyright" = "© 2026 Peter Steinberger. MIT-licens."; + +/* Debug Pane */ +"section_logging" = "Loggning"; +"enable_file_logging" = "Aktivera filloggning"; +"enable_file_logging_subtitle" = "Skriv loggar till %@ för felsökning."; +"verbosity_title" = "Detaljnivå"; +"verbosity_subtitle" = "Styr hur detaljerad loggningen är."; +"open_log_file" = "Öppna loggfil"; +"force_animation_next_refresh" = "Tvinga animation vid nästa uppdatering"; +"force_animation_next_refresh_subtitle" = "Visar tillfälligt laddningsanimationen efter nästa uppdatering."; +"section_loading_animations" = "Laddningsanimationer"; +"loading_animations_caption" = "Välj ett mönster och spela upp det i menyraden. \"Slumpmässig\" behåller nuvarande beteende."; +"animation_random_default" = "Slumpmässig (standard)"; +"replay_selected_animation" = "Spela vald animation igen"; +"blink_now" = "Blinka nu"; +"section_probe_logs" = "Probloggar"; +"probe_logs_caption" = "Hämta senaste probutdata för felsökning. Kopiera behåller hela texten."; +"fetch_log" = "Hämta logg"; +"copy" = "Kopiera"; +"save_to_file" = "Spara till fil"; +"load_parse_dump" = "Läs in tolkningsdump"; +"rerun_provider_autodetect" = "Kör automatisk leverantörsidentifiering igen"; +"loading" = "Läser in…"; +"no_log_yet_fetch" = "Ingen logg än. Hämta för att läsa in."; +"section_fetch_strategy" = "Försök med hämtningsstrategi"; +"fetch_strategy_caption" = "Senaste hämtningskedjans beslut och fel för en leverantör."; +"section_openai_cookies" = "OpenAI-cookies"; +"openai_cookies_caption" = "Loggar för cookieimport och WebKit-skrapning från senaste OpenAI-cookieförsöket."; +"no_log_yet" = "Ingen logg än. Uppdatera OpenAI-cookies under Leverantörer → Codex för att köra en import."; +"section_caches" = "Cachar"; +"caches_caption" = "Rensa cachade resultat från kostnadsskanningar eller webbläsarcookiecachar."; +"clear_cookie_cache" = "Rensa cookiecache"; +"clear_cost_cache" = "Rensa kostnadscache"; +"section_notifications" = "Aviseringar"; +"notifications_caption" = "Skicka testaviseringar för femtimmarsfönstret (slut/återställt)."; +"post_depleted" = "Skicka slut"; +"post_restored" = "Skicka återställd"; +"section_cli_sessions" = "CLI-sessioner"; +"cli_sessions_caption" = "Håll Codex/Claude-CLI-sessioner vid liv efter en prob. Standard är att avsluta när data har fångats."; +"keep_cli_sessions_alive" = "Håll CLI-sessioner vid liv"; +"keep_cli_sessions_alive_subtitle" = "Hoppa över nedstängning mellan prober (endast felsökning)."; +"reset_cli_sessions" = "Återställ CLI-sessioner"; +"section_error_simulation" = "Felsimulering"; +"error_simulation_caption" = "Infoga ett falskt felmeddelande i menykortet för layouttestning."; +"set_menu_error" = "Sätt menyfel"; +"clear_menu_error" = "Rensa menyfel"; +"set_cost_error" = "Sätt kostnadsfel"; +"clear_cost_error" = "Rensa kostnadsfel"; +"section_cli_paths" = "CLI-sökvägar"; +"cli_paths_caption" = "Löst Codex-binär och PATH-lager. Inloggningsskalets PATH fångas vid start (kort timeout)."; +"codex_binary" = "Codex-binär"; +"claude_binary" = "Claude-binär"; +"effective_path" = "Effektiv PATH"; +"unavailable" = "Inte tillgänglig"; +"login_shell_path" = "Inloggningsskalets PATH (fångad vid start)"; +"cleared" = "Rensat."; +"no_fetch_attempts" = "Inga hämtningsförsök än."; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe kan blockera menyradsappar i Systeminställningar → Menyrad → Tillåt i menyraden. CodexBar körs, men macOS kan dölja ikonen. Öppna menyradsinställningarna och slå på CodexBar."; + +/* Metric preferences */ +"metric_pref_automatic" = "Automatiskt"; +"metric_pref_primary" = "Primär"; +"metric_pref_secondary" = "Sekundär"; +"metric_pref_tertiary" = "Tertiär"; +"metric_pref_extra_usage" = "Extra användning"; +"metric_pref_average" = "Genomsnitt"; + +/* Display modes */ +"display_mode_percent" = "Procent"; +"display_mode_pace" = "Takt"; +"display_mode_both" = "Båda"; +"display_mode_percent_desc" = "Visa återstående/förbrukad procent (t.ex. 45 %)"; +"display_mode_pace_desc" = "Visa taktindikator (t.ex. +5 %)"; +"display_mode_both_desc" = "Visa både procent och takt (t.ex. 45 % · +5 %)"; + +/* Provider status */ +"status_operational" = "Fungerar normalt"; +"status_partial_outage" = "Delvis avbrott"; +"status_major_outage" = "Större avbrott"; +"status_critical_issue" = "Kritiskt problem"; +"status_maintenance" = "Underhåll"; +"status_unknown" = "Okänd status"; + +/* Refresh frequency */ +"refresh_manual" = "Manuellt"; +"refresh_1min" = "1 min"; +"refresh_2min" = "2 min"; +"refresh_5min" = "5 min"; +"refresh_15min" = "15 min"; +"refresh_30min" = "30 min"; + +/* Additional keys */ +"not_found" = "Hittades inte"; + +/* Cost estimation */ +"cost_header_estimated" = "Kostnad (uppskattad)"; +"cost_estimate_hint" = "Uppskattat från lokala loggar · kan skilja sig från din faktura"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "Ingen JetBrains IDE med AI Assistant hittades. Installera en JetBrains IDE och aktivera AI Assistant."; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "OpenRouter-API-token är inte konfigurerad. Ange miljövariabeln OPENROUTER_API_KEY eller konfigurera i Inställningar."; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "z.ai-API-token hittades inte. Ange apiKey i ~/.codexbar/config.json eller Z_AI_API_KEY."; +"Missing DeepSeek API key." = "DeepSeek-API-nyckel saknas."; +"%@ is unavailable in the current environment." = "%@ är inte tillgänglig i den aktuella miljön."; +"All Systems Operational" = "Alla system fungerar"; +"Last 30 days" = "Senaste 30 dagarna"; +"Last 30 days:" = "Senaste 30 dagarna:"; +"This month" = "Den här månaden"; +"Store multiple OpenAI API keys." = "Spara flera OpenAI-API-nycklar."; +"Admin API key" = "Admin-API-nyckel"; +"Open billing" = "Öppna fakturering"; +"Google accounts" = "Google-konton"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "Spara flera Google OAuth-konton för Antigravity så att du snabbt kan växla."; +"Add Google Account" = "Lägg till Google-konto"; +"Open Token Plan" = "Öppna tokenplan"; +"Text Generation" = "Textgenerering"; +"Text to Speech" = "Text till tal"; +"Music Generation" = "Musikgenerering"; +"Image Generation" = "Bildgenerering"; +"No local data found" = "Inga lokala data hittades"; +"Credits unavailable; keep Codex running to refresh." = "Krediter är inte tillgängliga. Håll Codex igång för att uppdatera."; +"No available fetch strategy for minimax." = "Ingen tillgänglig hämtningsstrategi för minimax."; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "Ingen Cursor-session hittades. Logga in på cursor.com i Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX eller Edge Canary. Om du använder Safari ger du CodexBar Fullständig skivåtkomst i Systeminställningar ▸ Integritet och säkerhet. Du kan också logga in på Cursor från CodexBar-menyn (lägg till/byt konto)."; +"No OpenCode session cookies found in browsers." = "Inga OpenCode-sessionscookies hittades i webbläsare."; +"No available fetch strategy for %@." = "Ingen tillgänglig hämtningsstrategi för %@."; +"Today" = "Idag"; +"Today tokens" = "Token idag"; +"30d cost" = "Kostnad 30 d"; +"30d tokens" = "Token 30 d"; +"Latest tokens" = "Senaste token"; +"Top model" = "Toppmodell"; +"Storage" = "Lagring"; +"Add Account..." = "Lägg till konto..."; +"Usage Dashboard" = "Användningsinstrumentpanel"; +"Status Page" = "Statussida"; +"Settings..." = "Inställningar..."; +"About CodexBar" = "Om CodexBar"; +"Quit" = "Avsluta"; +"Last %d day" = "Senaste %d dagen"; +"Last %d days" = "Senaste %d dagarna"; +"%@ tokens" = "%@ token"; +"Latest billing day" = "Senaste faktureringsdag"; +"Latest billing day (%@)" = "Senaste faktureringsdag (%@)"; +"%@ left" = "%@ kvar"; +"Resets %@" = "Återställs %@"; +"Resets in %@" = "Återställs om %@"; +"Resets now" = "Återställs nu"; +"Lasts until reset" = "Räcker till återställning"; +"Updated %@" = "Uppdaterad %@"; +"Updated %@h ago" = "Uppdaterad för %@ h sedan"; +"Updated %@m ago" = "Uppdaterad för %@ min sedan"; +"Updated just now" = "Uppdaterad nyss"; +"Projected empty in %@" = "Beräknas ta slut om %@"; +"Runs out in %@" = "Tar slut om %@"; +"Pace: %@" = "Takt: %@"; +"Pace: %@ · %@" = "Takt: %@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d %% risk att ta slut"; +"%d%% in deficit" = "%d %% underskott"; +"%d%% in reserve" = "%d %% reserv"; +"usage_percent_suffix_left" = "kvar"; +"usage_percent_suffix_used" = "förbrukat"; +"Store multiple DeepSeek API keys." = "Spara flera DeepSeek-API-nycklar."; +"This week" = "Den här veckan"; +"Week" = "Vecka"; +"Month" = "Månad"; +"Models" = "Modeller"; +"24h tokens" = "Token 24 h"; +"Latest hour" = "Senaste timmen"; +"Peak hour" = "Topptimme"; +"Top method" = "Toppmetod"; +"30d cash" = "Pengar 30 d"; +"30d billing history from MiniMax web session" = "Faktureringshistorik 30 d från MiniMax-webbsession"; +"AWS Cost Explorer billing can lag." = "AWS Cost Explorer-fakturering kan släpa efter."; +"Rate limit: %d / %@" = "Gräns: %d / %@"; +"Key remaining" = "Nyckel återstår"; +"No limit set for the API key" = "Ingen gräns är satt för API-nyckeln"; +"API key limit unavailable right now" = "API-nyckelgränsen är inte tillgänglig just nu"; +"This month: %@ tokens" = "Den här månaden: %@ token"; +"No utilization data yet." = "Inga utnyttjandedata än."; +"No %@ utilization data yet." = "Inga utnyttjandedata för %@ än."; +"%@: %@%% used" = "%@: %@ %% förbrukat"; +"%dd" = "%d d"; +"today" = "idag"; +"just now" = "nyss"; +"On pace" = "I takt"; +"Runs out now" = "Tar slut nu"; +"Projected empty now" = "Beräknas vara slut nu"; +"Switch Account..." = "Byt konto..."; +"Update ready, restart now?" = "Uppdatering redo. Starta om nu?"; +"Daily" = "Dagligen"; +"Hourly Tokens" = "Token per timme"; +"No data" = "Inga data"; +"No usage breakdown data available." = "Ingen användningsuppdelning tillgänglig."; + +"Today: %@ · %@ tokens" = "Idag: %@ · %@ token"; +"Today: %@" = "Idag: %@"; +"Today: %@ tokens" = "Idag: %@ token"; +"Last 30 days: %@ · %@ tokens" = "Senaste 30 dagarna: %@ · %@ token"; +"Last 30 days: %@" = "Senaste 30 dagarna: %@"; +"Est. total (30d): %@" = "Uppsk. totalt (30 d): %@"; +"Est. total (%@): %@" = "Uppsk. totalt (%@): %@"; +"Hover a bar for details" = "Håll pekaren över en stapel för detaljer"; +"%@: %@ · %@ tokens" = "%@: %@ · %@ token"; +"No providers selected for Overview." = "Inga leverantörer valda för översikten."; +"No overview data available." = "Inga översiktsdata tillgängliga."; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "Auto använder det lokala IDE-API:t först och sedan Google OAuth när IDE:n är stängd."; +"Login with Google" = "Logga in med Google"; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "Lägg till konton via GitHub OAuth Device Flow på vald värd."; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "Sparar varje inloggat Google-konto för snabb växling i Antigravity. Använder Antigravity.app OAuth när det finns, eller ANTIGRAVITY_OAUTH_CLIENT_ID och ANTIGRAVITY_OAUTH_CLIENT_SECRET som ersättning."; +"Manual cleanup: past sessions" = "Manuell rensning: tidigare sessioner"; +"Clearing removes past resume, continue, and rewind history." = "Rensning tar bort historik för tidigare återuppta, fortsätt och spola tillbaka."; +"Manual cleanup: file checkpoints" = "Manuell rensning: filkontrollpunkter"; +"Clearing removes checkpoint restore data for previous edits." = "Rensning tar bort återställningsdata från kontrollpunkter för tidigare ändringar."; +"Manual cleanup: saved plans" = "Manuell rensning: sparade planer"; +"Clearing removes old plan-mode files." = "Rensning tar bort gamla planlägesfiler."; +"Manual cleanup: debug logs" = "Manuell rensning: felsökningsloggar"; +"Clearing removes past debug logs." = "Rensning tar bort tidigare felsökningsloggar."; +"Manual cleanup: attachment cache" = "Manuell rensning: cache för bilagor"; +"Clearing removes cached large pastes or attached images." = "Rensning tar bort cachade stora inklistringar eller bifogade bilder."; +"Manual cleanup: session metadata" = "Manuell rensning: sessionsmetadata"; +"Clearing removes per-session environment metadata." = "Rensning tar bort miljömetadata per session."; +"Manual cleanup: shell snapshots" = "Manuell rensning: skalögonblicksbilder"; +"Clearing removes leftover runtime shell snapshot files." = "Rensning tar bort kvarvarande skalögonblicksbilder från körtid."; +"Manual cleanup: legacy todos" = "Manuell rensning: äldre att göra-listor"; +"Clearing removes legacy per-session task lists." = "Rensning tar bort äldre uppgiftslistor per session."; +"Manual cleanup: sessions" = "Manuell rensning: sessioner"; +"Clearing removes past Codex session history." = "Rensning tar bort tidigare Codex-sessionshistorik."; +"Manual cleanup: archived sessions" = "Manuell rensning: arkiverade sessioner"; +"Clearing removes archived Codex session history." = "Rensning tar bort arkiverad Codex-sessionshistorik."; +"Manual cleanup: cache" = "Manuell rensning: cache"; +"Clearing removes provider-owned cached data." = "Rensning tar bort leverantörsägda cachade data."; +"Manual cleanup: logs" = "Manuell rensning: loggar"; +"Clearing removes local diagnostic logs." = "Rensning tar bort lokala diagnostikloggar."; +"Manual cleanup: file history" = "Manuell rensning: filhistorik"; +"Clearing removes local edit checkpoint history." = "Rensning tar bort lokal redigeringshistorik från kontrollpunkter."; +"Manual cleanup: temporary data" = "Manuell rensning: tillfälliga data"; +"Clearing removes local temporary provider data." = "Rensning tar bort tillfälliga lokala leverantörsdata."; +"Total: %@" = "Totalt: %@"; +"%d more items" = "%d objekt till"; +"Cleanup ideas" = "Rensningsförslag"; +"%d unreadable item(s) skipped" = "%d oläsbara objekt hoppades över"; + +"API key limit" = "API-nyckelgräns"; +"Auth" = "Autentisering"; +"Auto" = "Auto"; +"Disabled — no recent data" = "Inaktiverad – inga färska data"; +"Limits not available" = "Gränser är inte tillgängliga"; +"No usage yet" = "Ingen användning än"; +"Not fetched yet" = "Inte hämtat än"; +"Refreshing" = "Uppdaterar"; +"Session" = "Session"; +"Source" = "Källa"; +"State" = "Tillstånd"; +"Unavailable" = "Inte tillgänglig"; +"Weekly" = "Vecka"; +"not detected" = "inte hittad"; +"Estimated from local Codex logs for the selected account." = "Uppskattat från lokala Codex-loggar för valt konto."; +"minimax_usage_amount_format" = "Användning: %@ / %@"; +"minimax_used_percent_format" = "Förbrukat %@"; +"minimax_service_text_generation" = "Textgenerering"; +"minimax_service_text_to_speech" = "Text till tal"; +"minimax_service_music_generation" = "Musikgenerering"; +"minimax_service_image_generation" = "Bildgenerering"; +"minimax_service_lyrics_generation" = "Låttextgenerering"; +"minimax_service_coding_plan_vlm" = "Coding plan VLM"; +"minimax_service_coding_plan_search" = "Coding plan-sökning"; + +/* Added after rebasing Swedish localization on current main. */ +"Open MiMo Balance" = "Öppna MiMo-saldo"; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "Sparas i ~/.codexbar/config.json. Mätvärden kräver åtkomst till Groq Enterprise Prometheus."; +"API spend" = "API-kostnad"; +"Open Command Code Settings" = "Öppna Command Code-inställningar"; +"Plan utilization chart" = "Diagram över plananvändning"; +"The browser login did not complete in time. Try Antigravity login again." = "Webbläsarinloggningen blev inte klar i tid. Försök logga in i Antigravity igen."; +"Organizations" = "Organisationer"; +"Your StepFun platform password. Used to login and obtain a session token." = "Ditt lösenord för StepFun-plattformen. Används för att logga in och hämta en sessionstoken."; +"Open this URL manually to continue login:\n\n%@" = "Öppna denna URL manuellt för att fortsätta inloggningen:\n\n%@"; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar kunde inte ersätta aktiv Codex-autentisering på den här Mac-datorn."; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "Sparas i ~/.codexbar/config.json. Används för /v1/quota-stats."; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar kunde inte spara det aktuella systemkontot säkert före bytet."; +"Oasis-Token" = "Oasis-Token"; +"Open Crof dashboard" = "Öppna Crof-översikt"; +"Sonnet" = "Sonnet"; +"No credits history data available." = "Ingen kredithistorik finns tillgänglig."; +"4 days" = "4 dagar"; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "Hemlig AWS-åtkomstnyckel. Kan även anges med AWS_SECRET_ACCESS_KEY."; +"Paste a Cookie header or cURL capture from %@." = "Klistra in en Cookie-header eller cURL-fångst från %@."; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "Visa eller dölj Kiro-krediter, procent eller båda bredvid menyradsikonen."; +"credits" = "krediter"; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om ”%@” så att webbläsarcookies kan dekrypteras och ditt konto autentiseras. Klicka på OK för att fortsätta."; +"StepFun platform account (phone number or email)." = "StepFun-plattformskonto (telefonnummer eller e-post)."; +"Timed out waiting for Cursor login. %@" = "Tidsgränsen nåddes i väntan på Cursor-inloggning. %@"; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Amp-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; +"Stored in ~/.codexbar/config.json." = "Sparas i ~/.codexbar/config.json."; +"Reported by Mistral billing usage." = "Rapporteras av Mistrals debiteringsanvändning."; +"Usage remaining" = "Användning kvar"; +"Usage used" = "Användning förbrukad"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\neller klistra in värdet för __Secure-next-auth.session-token"; +"Password" = "Lösenord"; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "Sparas i ~/.codexbar/config.json. Öppna Settings > Platform > API Keys i Warp och skapa en nyckel."; +"%d percent remaining" = "%d procent kvar"; +"Open Manus" = "Öppna Manus"; +"Unknown" = "Okänt"; +"Open Ollama API Keys" = "Öppna Ollama-API-nycklar"; +"Hourly Usage" = "Användning per timme"; +"Requests" = "Förfrågningar"; +"Antigravity login timed out" = "Antigravity-inloggning tog för lång tid"; +"%@ requests" = "%@ förfrågningar"; +"Open StepFun Platform" = "Öppna StepFun-plattformen"; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din OpenCode-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; +"Endpoint" = "Slutpunkt"; +"Paste the %@ JSON bundle from %@." = "Klistra in %@-JSON-paketet från %@."; +"Uses username + password to login and obtain an %@ automatically." = "Använder användarnamn och lösenord för att logga in och hämta ett %@ automatiskt."; +"Capacity Start" = "Kapacitetsstart"; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "Klistra in Oasis-Token från en inloggad webbläsarsession på platform.stepfun.com."; +"Claude Admin API 30 day spend trend" = "30-dagars kostnadstrend för Claude Admin API"; +"%@/%@ left" = "%@/%@ kvar"; +"Monthly" = "Månadsvis"; +"Gemini Flash" = "Gemini Flash"; +"Cost history chart" = "Diagram över kostnadshistorik"; +"Today requests" = "Dagens förfrågningar"; +"Kiro menu bar value" = "Kiro-värde i menyraden"; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar kunde inte läsa hanterad kontolagring. Återställ lagringen innan du lägger till ett konto till."; +"Using CLI fallback" = "Använder CLI-reserv"; +"%@ web API access is disabled." = "%@-åtkomst till webb-API är inaktiverad."; +"tokens" = "token"; +"%@ / %@ (%@ remaining)" = "%@ / %@ (%@ kvar)"; +"Zen balance" = "Zen-saldo"; +"Daily billing data finalizes at 07:00 UTC" = "Dagliga debiteringsdata fastställs kl. 07.00 UTC"; +"Add Account" = "Lägg till konto"; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar hittade ett annat hanterat konto som redan använder det aktuella systemkontot. Lös dubbletten innan du byter."; +"codex login exited with status %d." = "codex login avslutades med status %d."; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar kunde inte läsa sparad autentisering för kontot. Autentisera det igen och försök igen."; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\neller klistra in värdet för kimi-auth-token"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "Valfritt organisations-ID för konton som är kopplade till flera Anthropic-organisationer."; +"Manually paste an %@ from a browser session." = "Klistra in ett %@ manuellt från en webbläsarsession."; +"MiniMax 30 day token usage trend" = "30-dagars tokenanvändningstrend för MiniMax"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "Välj Moonshot/Kimi API-värd för internationella konton eller konton i Fastlandskina."; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "Sparas i ~/.codexbar/config.json. Hämta nyckeln från openrouter.ai/settings/keys och ange en köpgräns för nyckeln för att aktivera spårning av API-nyckelkvot."; +"Uses username + password to login and obtain an Oasis-Token automatically." = "Använder användarnamn och lösenord för att logga in och hämta en Oasis-Token automatiskt."; +"OpenRouter API key spend trend" = "Kostnadstrend för OpenRouter-API-nyckel"; +"Workspace ID" = "Arbetsyte-ID"; +"Refresh Session" = "Uppdatera session"; +"Today cash" = "Dagens kontanter"; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "Logga in på cursor.com i webbläsaren och uppdatera sedan Cursor i CodexBar."; +"Extra usage spent" = "Extra användning förbrukad"; +"5 days" = "5 dagar"; +"T3 Chat cookie" = "T3 Chat-cookie"; +"Full in ~1 regen" = "Full om cirka 1 regenerering"; +"DeepSeek 30 day token usage trend" = "30-dagars tokenanvändningstrend för DeepSeek"; +"Reorder" = "Ändra ordning"; +"Changelog" = "Ändringslogg"; +"Deployment" = "Distribution"; +"Quota usage" = "Kvotanvändning"; +"No system account" = "Inget systemkonto"; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "Sparas i ~/.codexbar/config.json. Kräver en Anthropic Admin API-nyckel."; +"AWS region. Can also be set with AWS_REGION." = "AWS-region. Kan även anges med AWS_REGION."; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Claude-cookie-header så att Claude-webbanvändning kan hämtas. Klicka på OK för att fortsätta."; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din z.ai-API-token så att användning kan hämtas. Klicka på OK för att fortsätta."; +"Show usage for organizations you belong to. Personal account is always shown." = "Visa användning för organisationer du tillhör. Personligt konto visas alltid."; +"%@ of %@ credits left" = "%@ av %@ krediter kvar"; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "Sparas i ~/.codexbar/config.json. Du kan också ange CODEBUFF_API_KEY eller låta CodexBar läsa ~/.config/manicode/credentials.json (skapas av `codebuff login`)."; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "Importerar Windsurf-sessionsdata från Chromium-webbläsarens localStorage automatiskt."; +"Full in ~%.0f regens" = "Full om cirka %.0f regenereringar"; +"Verbosity" = "Detaljnivå"; +"%d days of usage data across %d services" = "%d dagar med användningsdata för %d tjänster"; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "Sparas i ~/.codexbar/config.json. Du kan också ange KILO_API_KEY eller ~/.local/share/kilo/auth.json (kilo.access)."; +"Windsurf session JSON bundle" = "Windsurf-sessionspaket i JSON"; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Cursor-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; +"Drag to reorder" = "Dra för att ändra ordning"; +"cache-hit input" = "cacheträff-indata"; +"Automatically imports browser cookies." = "Importerar webbläsarcookies automatiskt."; +"Open Volcengine Ark Console" = "Öppna Volcengine Ark-konsol"; +"Antigravity login failed" = "Antigravity-inloggning misslyckades"; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "Åtkomst till Nyckelring är inaktiverad under Avancerat, så import av webbläsarcookies är inte tillgänglig."; +"Browser cookies" = "Webbläsarcookies"; +"Usage history (%d days)" = "Användningshistorik (%d dagar)"; +"Cache read" = "Cacheläsning"; +"Copied" = "Kopierat"; +"Disable %@ dashboard cookie usage." = "Inaktivera cookie-användning för %@-översikten."; +"30d requests" = "30 d förfrågningar"; +"Adding Account…" = "Lägger till konto…"; +"Base URL" = "Bas-URL"; +"Utilization End" = "Användningsslut"; +"7d spend" = "7 d kostnad"; +"CodexBar could not read the current system account on this Mac." = "CodexBar kunde inte läsa det aktuella systemkontot på den här Mac-datorn."; +"%@ of %@ bonus credits left" = "%@ av %@ bonuskrediter kvar"; +"Overage usage" = "Överförbrukning"; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS-åtkomstnyckel-ID. Kan även anges med AWS_ACCESS_KEY_ID."; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar kunde inte hitta sparad autentisering för kontot. Autentisera det igen och försök igen."; +"Capacity End" = "Kapacitetsslut"; +"Paste the %@ value or a full Cookie header." = "Klistra in %@-värdet eller en fullständig Cookie-header."; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Factory-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; +"Paste a Cookie header or full cURL capture from %@." = "Klistra in en Cookie-header eller fullständig cURL-fångst från %@."; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "Klistra in Cookie-headern från en förfrågan till admin.mistral.ai. Den måste innehålla en ory_session_*-cookie."; +"Copy error" = "Kopieringsfel"; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din MiniMax-API-token så att användning kan hämtas. Klicka på OK för att fortsätta."; +"Project ID" = "Projekt-ID"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "Tidsgränsen nåddes i väntan på Cursor-inloggning. %@ Senaste fel: %@"; +"codex_login_output" = "utdata från codex login:"; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "Sparas i ~/.codexbar/config.json. Hämta API-nyckeln från Volcengine Ark-konsolen."; +"Overage cost" = "Kostnad för överförbrukning"; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar kan inte ersätta ett systemkonto som är inloggat med en konfiguration som bara använder API-nyckel."; +"Utilization Start" = "Användningsstart"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API-nyckeln verifierar åtkomst till Ollama Cloud. Cookies visar fortfarande kvotgränser."; +"Credits remaining" = "Krediter kvar"; +"Activity" = "Aktivitet"; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "Sparas i ~/.codexbar/config.json. Hämta nyckeln från console.deepgram.com."; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI-distributionens namn. AZURE_OPENAI_DEPLOYMENT_NAME stöds också."; +"Extra usage balance: %@" = "Saldo för extra användning: %@"; +"requests" = "förfrågningar"; +"CodexBar could not save the current system account before switching." = "CodexBar kunde inte spara det aktuella systemkontot före bytet."; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "Sparas i ~/.codexbar/config.json. För det officiella Kimi-API:t använder du Moonshot / Kimi API."; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\neller klistra in en cURL-fångst från Abacus AI-översikten"; +"Last 30 days: %@ tokens" = "Senaste 30 dagarna: %@ token"; +"%@ authentication is disabled." = "%@-autentisering är inaktiverad."; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din GitHub Copilot-token så att användning kan hämtas. Klicka på OK för att fortsätta."; +"z.ai hourly token trend" = "Token-trend per timme för z.ai"; +"Near full" = "Nästan full"; +"Open Moonshot Console" = "Öppna Moonshot-konsol"; +"Open T3 Chat Settings" = "Öppna T3 Chat-inställningar"; +"after next regen" = "efter nästa regenerering"; +"%.0f%% used" = "%.0f%% använt"; +"claude /login exited with status %d." = "claude /login avslutades med status %d."; +"%d days of cost data" = "%d dagar med kostnadsdata"; +"Open legacy provider docs" = "Öppna äldre leverantörsdokumentation"; +"Secret access key" = "Hemlig åtkomstnyckel"; +"Region" = "Region"; +"Paste a Cookie header captured from %@." = "Klistra in en Cookie-header fångad från %@."; +"Credits history chart" = "Diagram över kredithistorik"; +"Re-authenticating…" = "Autentiserar igen…"; +"Paste a full cookie header or the %@ value." = "Klistra in en fullständig cookie-header eller %@-värdet."; +"Reload" = "Läs in igen"; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "Sparas i ~/.codexbar/config.json. OPENAI_ADMIN_KEY föredras, men OPENAI_API_KEY fungerar fortfarande."; +"Access key ID" = "Åtkomstnyckel-ID"; +"No output captured." = "Ingen utdata fångades."; +"Refresh organizations" = "Uppdatera organisationer"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\neller klistra bara in session_id-värdet"; +"Open Codebuff Dashboard" = "Öppna Codebuff-översikt"; +"7 days" = "7 dagar"; +"output" = "utdata"; +"Simulated error text" = "Simulerad feltext"; +"Regenerates %@" = "Regenererar %@"; +"Usage history (today)" = "Användningshistorik (i dag)"; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "Valfritt. Lämna tomt för att hitta och slå ihop projekt som är synliga för API-nyckeln."; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "Sparas i ~/.codexbar/config.json. Hämta nyckeln från elevenlabs.io/app/settings/api-keys."; +"Automatic imports browser cookies from Bailian." = "Importerar webbläsarcookies från Bailian automatiskt."; +"Enterprise host" = "Enterprise-värd"; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "Valfritt. Ange din GitHub Enterprise-värd, till exempel octocorp.ghe.com. Lämna tomt för github.com."; +"%d utilization samples" = "%d användningsmätningar"; +"Cap start" = "Gränsstart"; +"Credits used" = "Använda krediter"; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din OpenAI-cookie-header så att extra Codex-översiktsdata kan hämtas. Klicka på OK för att fortsätta."; +"Re-auth" = "Autentisera igen"; +"cache-miss input" = "cachemiss-indata"; +"Day" = "Dag"; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "Valfritt. Gäller den konfigurerade Admin API-nyckeln. Valda tokenkonton ärver inte OPENAI_PROJECT_ID."; +"Balance updates in near-real time (up to 5 min lag)" = "Saldot uppdateras nästan i realtid (upp till 5 min fördröjning)"; +"Cap end" = "Gränsslut"; +"Personal account" = "Personligt konto"; +"Automatically imports browser session cookies." = "Importerar webbläsarens sessionscookies automatiskt."; +"Label" = "Etikett"; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "Sparas i ~/.codexbar/config.json. Hämta nyckeln från Ollama-inställningarna."; +"Open Augment (Log Out & Back In)" = "Öppna Augment (logga ut och in igen)"; +"Overages" = "Överförbrukning"; +"Open projects" = "Öppna projekt"; +"Reported by OpenAI Admin API organization usage." = "Rapporteras av OpenAI Admin API:s organisationsanvändning."; +"%@: %@ credits" = "%@: %@ krediter"; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Kimi-autentiseringstoken så att användning kan hämtas. Klicka på OK för att fortsätta."; +"Keychain Access Required" = "Åtkomst till Nyckelring krävs"; +"Username" = "Användarnamn"; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din MiniMax-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; +"30d spend" = "30 d kostnad"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "API-nyckeln har verifierats. Ollama exponerar inte Cloud-kvotgränser via API:t."; +"Series" = "Serie"; +"Total (30d): %@ credits" = "Totalt (30 d): %@ krediter"; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "Klistra in en Cookie-header eller fullständig cURL-fångst från T3 Chat-inställningarna."; +"%d days of credits data" = "%d dagar med kreditdata"; +"used after next regen" = "använt efter nästa regenerering"; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Kimi K2-API-nyckel så att användning kan hämtas. Klicka på OK för att fortsätta."; +"Usage breakdown chart" = "Diagram över användningsfördelning"; +"Could not open browser for Antigravity" = "Kunde inte öppna webbläsare för Antigravity"; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "Sparas i ~/.codexbar/config.json. AZURE_OPENAI_API_KEY stöds också."; +"Could not open Cursor login in your browser." = "Kunde inte öppna Cursor-inloggning i webbläsaren."; +"Latest" = "Senaste"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI-resursens slutpunkt. AZURE_OPENAI_ENDPOINT stöds också."; +"No organizations loaded. Click Refresh after setting your API key." = "Inga organisationer har lästs in. Klicka på Uppdatera efter att du har angett API-nyckeln."; +"Copy path" = "Kopiera sökväg"; +"Paste a Cookie header from %@." = "Klistra in en Cookie-header från %@."; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om OAuth-token för Claude Code så att din Claude-användning kan hämtas. Klicka på OK för att fortsätta."; +"Base URL for the LLM-API-Key-Proxy instance." = "Bas-URL för LLM-API-Key-Proxy-instansen."; +"Paste a Cookie or Authorization header from %@." = "Klistra in en Cookie- eller Authorization-header från %@."; +"stale data" = "inaktuella data"; +"Quota" = "Kvot"; +"Auth source" = "Autentiseringskälla"; +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "Importerar Chrome-cookies från Xiaomi MiMo automatiskt."; +"No usage configured." = "Ingen användning konfigurerad."; +"Extra usage" = "Extra användning"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "Kontot finns inte längre i CodexBar. Uppdatera kontolistan och försök igen."; +"%@ is waiting for permission" = "%@ väntar på behörighet"; +"Org ID (optional)" = "Org-ID (valfritt)"; +"Service" = "Tjänst"; +"Azure OpenAI key" = "Azure OpenAI-nyckel"; +"%@ cookies are disabled." = "%@-cookies är inaktiverade."; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "Sparas i ~/.codexbar/config.json. Du kan också ange CROF_API_KEY."; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Synthetic-API-nyckel så att användning kan hämtas. Klicka på OK för att fortsätta."; +"CodexBar could not update managed account storage." = "CodexBar kunde inte uppdatera hanterad kontolagring."; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar kommer att be macOS Nyckelring om din Augment-cookie-header så att användning kan hämtas. Klicka på OK för att fortsätta."; +"Clear" = "Rensa"; +"No matching providers" = "Inga matchande leverantörer"; +"Search providers" = "Sök leverantörer"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index b39dfc2e..191f3fea 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -7,8 +7,8 @@ "API key" = "API 密钥"; "API key limit" = "API 密钥限制"; "API region" = "API 区域"; -"API token" = "API 词元"; -"API tokens" = "API 词元"; +"API token" = "API 令牌"; +"API tokens" = "API 令牌"; "About" = "关于"; "Account" = "账户"; "Accounts" = "账户"; @@ -50,7 +50,7 @@ "Bordered" = "带边框"; "Build" = "构建"; "Built \\(buildTimestamp)" = "构建于 \\(buildTimestamp)"; -"Buy Credits..." = "购买额度..."; +"Buy Credits..." = "购买额度…"; "Buy Credits…" = "购买额度…"; "CLI paths" = "CLI 路径"; "CLI sessions" = "CLI 会话"; @@ -101,14 +101,13 @@ "Could not start codex login" = "无法启动 codex login"; "Could not switch system account" = "无法切换系统账户"; "Credits" = "额度"; -"Credits history" = "额度历史"; +"Credits history" = "额度记录"; "Cursor login failed" = "Cursor 登录失败"; "Custom" = "自定义"; "Custom Path" = "自定义路径"; -"Daily Routines" = "日常例程"; +"Daily Routines" = "日常任务"; "Debug" = "调试"; "Default" = "默认"; -"Designs" = "设计"; "Disable Keychain access" = "禁用钥匙串访问"; "Disabled" = "已禁用"; "Disabled — no recent data" = "已禁用 — 无近期数据"; @@ -171,7 +170,7 @@ "Managed Codex accounts unavailable" = "托管 Codex 账户不可用"; "Managed account storage is unreadable. Live account access is still available, " = "托管账户存储不可读。实时账户访问仍可用,"; "Manual" = "手动"; -"May your tokens never run out—keep agent limits in view." = "愿你的词元永不耗尽——随时关注智能体限制。"; +"May your tokens never run out—keep agent limits in view." = "愿你的 token 永不耗尽——随时关注智能体额度。"; "Menu bar" = "菜单栏"; "Menu bar auto-shows the provider closest to its rate limit." = "菜单栏会自动显示最接近速率限制的提供商。"; "Menu bar metric" = "菜单栏指标"; @@ -185,12 +184,12 @@ "No cost history data." = "暂无费用历史数据。"; "No usage yet" = "尚无用量"; "Not fetched yet" = "尚未获取"; -"No credits history data." = "暂无额度历史数据。"; +"No credits history data." = "暂无额度记录。"; "No data available" = "无可用数据"; "No data yet" = "暂无数据"; "No enabled providers available for Overview." = "“概览”中没有可用的已启用提供商。"; "No providers selected" = "未选择提供商"; -"No token accounts yet." = "尚无词元账户。"; +"No token accounts yet." = "尚无令牌账户。"; "No usage breakdown data." = "暂无用量明细数据。"; "None" = "无"; "Notifications" = "通知"; @@ -320,7 +319,7 @@ "The default Codex account on this Mac." = "此 Mac 上的默认 Codex 账户。"; "Toggle" = "切换"; "Toggle subtitle" = "切换副标题"; -"Token" = "词元"; +"Token" = "token"; "Trigger the menu bar menu from anywhere." = "从任意位置触发菜单栏菜单。"; "True" = "真"; "Twitter" = "Twitter"; @@ -346,7 +345,7 @@ "Website" = "网站"; "Weekly" = "每周"; "Weekly limit confetti" = "每周限制彩纸"; -"Weekly token limit" = "每周词元限制"; +"Weekly token limit" = "每周 token 限制"; "Weekly usage" = "每周用量"; "Weekly usage unavailable for this account." = "此账户的每周用量不可用。"; "Window: \\(window)" = "窗口:\\(window)"; @@ -405,6 +404,7 @@ "language_spanish" = "Español"; "language_catalan" = "Català"; "language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; "language_portuguese_brazilian" = "Português (Brasil)"; "start_at_login_title" = "开机启动"; "start_at_login_subtitle" = "启动 Mac 时自动打开 CodexBar。"; @@ -420,7 +420,7 @@ "session_quota_notifications_title" = "会话配额通知"; "session_quota_notifications_subtitle" = "当 5 小时会话配额用完及恢复时发送通知。"; "quota_warning_notifications_title" = "配额预警通知"; -"quota_warning_notifications_subtitle" = "当会话或每周剩余配额低于设定阈值时提醒。"; +"quota_warning_notifications_subtitle" = "当会话或每周剩余配额低于设置的阈值时提醒。"; "quota_warnings_title" = "配额预警"; "quota_warning_session" = "会话"; "quota_warning_session_capitalized" = "会话"; @@ -456,6 +456,7 @@ "remove" = "移除"; "managed_login_already_running" = "托管 Codex 登录已在运行。请等待完成后再添加或重新认证其他账户。"; "managed_login_failed" = "托管 Codex 登录未完成。请先在终端确认 `codex --version` 可以运行。如果 macOS 阻止了 `codex` 或将它移到废纸篓,请移除旧的重复安装,运行 `npm install -g --include=optional @openai/codex@latest`,然后重试。"; +"codex_login_output" = "codex login 输出:"; "managed_login_missing_email" = "Codex 登录已完成,但无法获取账户邮箱。请在确认账户已完全登录后重试。"; "workspace_selection_cancelled" = "CodexBar 发现多个工作区,但未选择任何工作区。"; "unsafe_managed_home" = "CodexBar 拒绝修改意外的托管主目录路径:%@"; @@ -522,7 +523,7 @@ "keychain_access_caption" = "禁用所有钥匙串读写。浏览器 Cookie 导入不可用;请在“提供商”中手动粘贴 Cookie 标头。"; "disable_keychain_access_title" = "禁用钥匙串访问"; "disable_keychain_access_subtitle" = "启用时阻止任何钥匙串访问。"; -"about_tagline" = "愿你的词元永不耗尽——随时关注智能体限制。"; +"about_tagline" = "愿你的 token 永不耗尽——随时关注智能体额度。"; "link_github" = "GitHub"; "link_website" = "网站"; "link_twitter" = "Twitter"; @@ -644,6 +645,418 @@ "macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe 可能会在“系统设置”→“菜单栏”→“允许显示在菜单栏”中阻止菜单栏应用。CodexBar 正在运行,但 macOS 可能隐藏了它的图标。请打开菜单栏设置并启用 CodexBar。"; "cost_header_estimated" = "费用(估算)"; "cost_estimate_hint" = "根据本地日志估算 · 可能与账单不同"; +"Estimated from local Codex logs for the selected account." = "根据所选账户的本地 Codex 日志估算。"; +"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant." = "未检测到启用 AI Assistant 的 JetBrains IDE。请安装 JetBrains IDE 并启用 AI Assistant。"; +"OpenRouter API token not configured. Set OPENROUTER_API_KEY environment variable or configure in Settings." = "未配置 OpenRouter API 令牌。请设置 OPENROUTER_API_KEY 环境变量,或在“设置”中配置。"; +"z.ai API token not found. Set apiKey in ~/.codexbar/config.json or Z_AI_API_KEY." = "未找到 z.ai API 令牌。请在 ~/.codexbar/config.json 中设置 apiKey,或设置 Z_AI_API_KEY。"; +"Missing DeepSeek API key." = "缺少 DeepSeek API 密钥。"; +"%@ is unavailable in the current environment." = "%@ 在当前环境不可用。"; +"All Systems Operational" = "系统全部正常"; +"Last 30 days" = "近 30 天"; +"Last 30 days:" = "近 30 天:"; +"This month" = "本月"; +"Store multiple OpenAI API keys." = "存储多个 OpenAI API 密钥。"; +"Admin API key" = "管理员 API 密钥"; +"Open billing" = "打开账单"; +"Google accounts" = "Google 账户"; +"Store multiple Antigravity Google OAuth accounts for quick switching." = "存储多个 Antigravity Google OAuth 账户以便快速切换。"; +"Stores each signed-in Google account for quick Antigravity switching. Uses Antigravity.app OAuth when available, or ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET as an override." = "保存每个已登录的 Google 账户,便于快速切换 Antigravity。优先使用 Antigravity.app OAuth;也可使用 ANTIGRAVITY_OAUTH_CLIENT_ID 和 ANTIGRAVITY_OAUTH_CLIENT_SECRET 作为覆盖。"; +"Add Google Account" = "添加 Google 账户"; +"Open Token Plan" = "打开 Token 套餐页"; +"Text Generation" = "文本生成"; +"Text to Speech" = "文本转语音"; +"Music Generation" = "音乐生成"; +"Image Generation" = "图像生成"; +"No local data found" = "未找到本地数据"; +"Credits unavailable; keep Codex running to refresh." = "额度暂不可用;请保持 Codex 运行后再刷新。"; +"No available fetch strategy for minimax." = "没有可用的 MiniMax 抓取策略。"; +"No Cursor session found. Please log in to cursor.com in Safari, Chrome, Microsoft Edge, Brave, Arc, Dia, ChatGPT Atlas, Chromium, Helium, Vivaldi, Yandex Browser, Firefox, Zen, Colibri, Sidekick, Opera, Opera GX, or Edge Canary. If you use Safari, grant CodexBar Full Disk Access in System Settings ▸ Privacy & Security. You can also sign in to Cursor from the CodexBar menu (Add / switch account)." = "未找到 Cursor 会话。请在 Safari、Chrome、Microsoft Edge、Brave、Arc、Dia、ChatGPT Atlas、Chromium、Helium、Vivaldi、Yandex Browser、Firefox、Zen、Colibri、Sidekick、Opera、Opera GX 或 Edge Canary 中登录 cursor.com。若使用 Safari,请在“系统设置 ▸ 隐私与安全性”中授予 CodexBar“完全磁盘访问权限”。你也可以在 CodexBar 菜单中登录 Cursor(添加/切换账户)。"; +"No OpenCode session cookies found in browsers." = "在浏览器中未找到 OpenCode 会话 Cookie。"; +"No available fetch strategy for %@." = "%@ 暂无可用的获取策略。"; +"Today" = "今日"; +"Today tokens" = "今日 token 用量"; +"30d cost" = "近 30 天费用"; +"30d tokens" = "近 30 天 token 用量"; +"Latest tokens" = "最近 token 用量"; +"Top model" = "最常用模型"; +"Storage" = "存储"; +"Add Account..." = "添加账户…"; +"Usage Dashboard" = "用量仪表盘"; +"Status Page" = "状态页"; +"Settings..." = "设置…"; +"About CodexBar" = "关于 CodexBar"; +"Quit" = "退出"; +"Last %d day" = "近 %d 天"; +"Last %d days" = "近 %d 天"; +"%@ tokens" = "%@ token 用量"; +"Latest billing day" = "最近结算日"; +"Latest billing day (%@)" = "最近结算日(%@)"; +"%@ left" = "%@ 剩余"; +"Resets %@" = "重置于 %@"; +"Resets in %@" = "%@后重置"; +"Resets now" = "立即重置"; +"Lasts until reset" = "持续到重置"; +"Updated %@" = "更新于 %@"; +"Updated %@h ago" = "%@ 小时前更新"; +"Updated %@m ago" = "%@ 分钟前更新"; +"Updated just now" = "刚刚更新"; +"Projected empty in %@" = "预计 %@ 后耗尽"; +"Runs out in %@" = "预计 %@ 后耗尽"; +"Pace: %@" = "节奏:%@"; +"Pace: %@ · %@" = "节奏:%@ · %@"; +"%@ · %@" = "%@ · %@"; +"≈ %d%% run-out risk" = "≈ %d%% 耗尽风险"; +"%d%% in deficit" = "超额 %d%%"; +"%d%% in reserve" = "余量 %d%%"; +"usage_percent_suffix_left" = "剩余"; +"usage_percent_suffix_used" = "已使用"; +"Store multiple DeepSeek API keys." = "存储多个 DeepSeek API 密钥。"; +"This week" = "本周"; +"Week" = "本周"; +"Month" = "本月"; +"Models" = "模型数"; +"24h tokens" = "24 小时 token 用量"; +"Latest hour" = "最近 1 小时"; +"Peak hour" = "峰值小时"; +"Top method" = "主要方法"; +"30d cash" = "近 30 天费用"; +"30d billing history from MiniMax web session" = "来自 MiniMax Web 会话的近 30 天账单历史"; +"AWS Cost Explorer billing can lag." = "AWS Cost Explorer 账单数据可能延迟。"; +"Rate limit: %d / %@" = "速率限制:%d / %@"; +"Key remaining" = "密钥剩余额度"; +"No limit set for the API key" = "该 API 密钥未设置上限"; +"API key limit unavailable right now" = "当前无法获取 API 密钥上限"; +"This month: %@ tokens" = "本月:%@ token"; +"No utilization data yet." = "暂无使用率数据。"; +"No %@ utilization data yet." = "暂无 %@ 使用率数据。"; +"%@: %@%% used" = "%@:已用 %@%%"; +"%dd" = "%d 天"; +"today" = "今天"; +"just now" = "刚刚"; +"On pace" = "节奏正常"; +"Runs out now" = "即将耗尽"; +"Projected empty now" = "即将耗尽"; +"Switch Account..." = "切换账户…"; +"Update ready, restart now?" = "更新已就绪,是否立即重启?"; +"Daily" = "每日"; +"Hourly Tokens" = "每小时 token 用量"; +"No data" = "暂无数据"; +"No usage breakdown data available." = "暂无可用用量明细数据。"; +"Today: %@ · %@ tokens" = "今日:%@ · %@ token"; +"Today: %@" = "今日:%@"; +"Today: %@ tokens" = "今日:%@ token"; +"Last 30 days: %@ · %@ tokens" = "近 30 天:%@ · %@ token"; +"Last 30 days: %@" = "近 30 天:%@"; +"Est. total (30d): %@" = "近 30 天估算总计:%@"; +"Est. total (%@): %@" = "估算总计(%@):%@"; +"Hover a bar for details" = "悬停在柱形图上查看详情"; +"%@: %@ · %@ tokens" = "%@:%@ · %@ token"; +"No providers selected for Overview." = "概览中尚未选择提供商。"; +"No overview data available." = "概览暂无可用数据。"; +"Auto uses the local IDE API first, then Google OAuth when the IDE is closed." = "自动模式会优先使用本地 IDE API;当 IDE 关闭时再使用 Google OAuth。"; +"Login with Google" = "使用 Google 登录"; +"Google OAuth" = "Google OAuth"; +"Add accounts via GitHub OAuth Device Flow on the selected host." = "通过所选主机的 GitHub OAuth 设备流程添加账户。"; +"Manual cleanup: past sessions" = "手动清理:历史会话"; +"Clearing removes past resume, continue, and rewind history." = "清理后将移除历史恢复、继续和回退记录。"; +"Manual cleanup: file checkpoints" = "手动清理:文件检查点"; + +/* Popup panels */ +"No usage configured." = "尚未配置用量。"; +"Quota" = "配额"; +"tokens" = "token"; +"requests" = "请求"; +"Latest" = "最新"; +"Monthly" = "每月"; +"Sonnet" = "Sonnet"; +"Overages" = "超额"; +"Activity" = "活动"; +"Copied" = "已复制"; +"Copy error" = "复制错误"; +"Copy path" = "复制路径"; +"Extra usage spent" = "额外用量支出"; +"Credits remaining" = "剩余额度"; +"Using CLI fallback" = "使用 CLI 回退"; +"Balance updates in near-real time (up to 5 min lag)" = "余额接近实时更新(最多延迟 5 分钟)"; +"Daily billing data finalizes at 07:00 UTC" = "每日账单数据会在 UTC 07:00 完成结算"; +"%@ of %@ credits left" = "剩余 %@ / %@ 点额度"; +"%@ of %@ bonus credits left" = "剩余 %@ / %@ 点奖励额度"; +"%@ / %@ (%@ remaining)" = "%@ / %@(剩余 %@)"; +"%@/%@ left" = "剩余 %@ / %@"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "%@后恢复"; +"used after next regen" = "下次恢复后已使用"; +"after next regen" = "下次恢复后"; +"Near full" = "接近全满"; +"Full in ~1 regen" = "约 1 次恢复后全满"; +"Full in ~%.0f regens" = "约 %.0f 次恢复后全满"; +"Overage usage" = "超额用量"; +"Overage cost" = "超额费用"; +"credits" = "额度"; +"Zen balance" = "Zen 余额"; +"API spend" = "API 支出"; +"Extra usage" = "额外用量"; +"Quota usage" = "配额用量"; +"%.0f%% used" = "已使用 %.0f%%"; +"Usage history (today)" = "用量记录(今天)"; +"Usage history (%d days)" = "用量记录(%d 天)"; +"%d percent remaining" = "剩余 %d%%"; +"Unknown" = "未知"; +"stale data" = "数据过旧"; +"No credits history data available." = "暂无可用额度记录数据。"; +"Credits history chart" = "额度记录图表"; +"%d days of credits data" = "%d 天额度数据"; +"Usage breakdown chart" = "用量明细图表"; +"%d days of usage data across %d services" = "%d 天用量数据,涵盖 %d 个服务"; +"Cost history chart" = "费用记录图表"; +"%d days of cost data" = "%d 天费用数据"; +"Plan utilization chart" = "套餐使用率图表"; +"%d utilization samples" = "%d 条使用率样本"; +"Hourly Usage" = "每小时用量"; +"Usage remaining" = "剩余用量"; +"Usage used" = "已使用用量"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "API 密钥已验证。Ollama 不会通过 API 暴露 Cloud 配额限制。"; +"Last 30 days: %@ tokens" = "近 30 天:%@ token"; +"7d spend" = "7 天支出"; +"30d spend" = "30 天支出"; +"Cache read" = "缓存读取"; +"Claude Admin API 30 day spend trend" = "Claude Admin API 30 天支出趋势"; +"OpenRouter API key spend trend" = "OpenRouter API 密钥支出趋势"; +"z.ai hourly token trend" = "z.ai 每小时 token 趋势"; +"MiniMax 30 day token usage trend" = "MiniMax 30 天 token 用量趋势"; +"Today cash" = "今日现金"; +"DeepSeek 30 day token usage trend" = "DeepSeek 30 天 token 用量趋势"; +"cache-hit input" = "缓存命中输入"; +"cache-miss input" = "缓存未命中输入"; +"output" = "输出"; +"Requests" = "请求"; +"Reported by OpenAI Admin API organization usage." = "由 OpenAI Admin API 组织用量报告。"; +"Reported by Mistral billing usage." = "由 Mistral 账单用量报告。"; +"Clearing removes checkpoint restore data for previous edits." = "清理后将移除以往编辑的检查点恢复数据。"; +"Manual cleanup: saved plans" = "手动清理:已保存计划"; +"Clearing removes old plan-mode files." = "清理后将移除旧的计划模式文件。"; +"Manual cleanup: debug logs" = "手动清理:调试日志"; +"Clearing removes past debug logs." = "清理后会移除历史调试日志。"; +"Manual cleanup: attachment cache" = "手动清理:附件缓存"; +"Clearing removes cached large pastes or attached images." = "清理后会移除缓存的大段粘贴内容或附件图片。"; +"Manual cleanup: session metadata" = "手动清理:会话元数据"; +"Clearing removes per-session environment metadata." = "清理后会移除每个会话的环境元数据。"; +"Manual cleanup: shell snapshots" = "手动清理:Shell 快照"; +"Clearing removes leftover runtime shell snapshot files." = "清理后会移除遗留的运行时 Shell 快照文件。"; +"Manual cleanup: legacy todos" = "手动清理:旧版待办"; +"Clearing removes legacy per-session task lists." = "清理后会移除旧版的每会话任务列表。"; +"Manual cleanup: sessions" = "手动清理:会话"; +"Clearing removes past Codex session history." = "清理后将移除历史 Codex 会话记录。"; +"Manual cleanup: archived sessions" = "手动清理:归档会话"; +"Clearing removes archived Codex session history." = "清理后会移除已归档的 Codex 会话记录。"; +"Manual cleanup: cache" = "手动清理:缓存"; +"Clearing removes provider-owned cached data." = "清理后将移除提供商缓存数据。"; +"Manual cleanup: logs" = "手动清理:日志"; +"Clearing removes local diagnostic logs." = "清理后将移除本地诊断日志。"; +"Manual cleanup: file history" = "手动清理:文件历史"; +"Clearing removes local edit checkpoint history." = "清理后会移除本地编辑检查点历史。"; +"Manual cleanup: temporary data" = "手动清理:临时数据"; +"Clearing removes local temporary provider data." = "清理后会移除本地临时提供商数据。"; +"Total: %@" = "总计:%@"; +"%d more items" = "另有 %d 项"; +"Cleanup ideas" = "清理建议"; +"%d unreadable item(s) skipped" = "已跳过 %d 个不可读项目"; +"weekly_progress_work_days_title" = "工作日刻度线"; +"weekly_progress_work_days_subtitle" = "在每周用量条上显示按天分隔的刻度线。"; "copilot_device_code" = "设备代码已复制到剪贴板:%1$@\n\n请在以下地址验证:%2$@"; "copilot_waiting_text" = "请在浏览器中完成登录。\n登录完成后,此窗口会自动关闭。"; "vertex_ai_login_instructions" = "要跟踪 Vertex AI 用量,请通过 Google Cloud 进行认证。\n\n1. 打开终端\n2. 运行:gcloud auth application-default login\n3. 按照浏览器提示登录\n4. 设置你的项目:gcloud config set project PROJECT_ID\n\n是否现在打开终端?"; +"minimax_usage_amount_format" = "用量:%@ / %@"; +"minimax_used_percent_format" = "已使用 %@"; +"minimax_service_text_generation" = "文本生成"; +"minimax_service_text_to_speech" = "文本转语音"; +"minimax_service_music_generation" = "音乐生成"; +"minimax_service_image_generation" = "图像生成"; +"minimax_service_lyrics_generation" = "歌词生成"; +"minimax_service_coding_plan_vlm" = "视觉编码计划"; +"minimax_service_coding_plan_search" = "搜索编码计划"; + +/* Notification strings */ +"login_success_notification_title" = "%@ 登录成功"; +"login_success_notification_body" = "可以返回应用了,认证已完成。"; +"session_depleted_notification_title" = "%@ 会话额度已用尽"; +"session_depleted_notification_body" = "剩余 0%。可用时会通知你。"; +"session_restored_notification_title" = "%@ 会话已恢复"; +"session_restored_notification_body" = "会话额度已重新可用。"; +"quota_warning_notification_title" = "%1$@ 的 %2$@ 额度偏低"; +"quota_warning_notification_body" = "剩余 %1$@。已达到 %2$d%% 的 %3$@ 预警阈值。"; +"quota_warning_notification_body_with_account" = "账户 %1$@。剩余 %2$@。已达到 %3$d%% 的 %4$@ 预警阈值。"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ 正在等待权限"; +"%@ requests" = "%@ 个请求"; +"%@: %@ credits" = "%@:%@ 额度"; +"30d requests" = "近 30 天请求"; +"4 days" = "4 天"; +"5 days" = "5 天"; +"7 days" = "7 天"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API 密钥会验证 Ollama Cloud 访问权限;Cookie 仍会提供配额限制。"; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS 访问密钥 ID。也可以用 AWS_ACCESS_KEY_ID 设置。"; +"AWS region. Can also be set with AWS_REGION." = "AWS 区域。也可以用 AWS_REGION 设置。"; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS 秘密访问密钥。也可以用 AWS_SECRET_ACCESS_KEY 设置。"; +"Access key ID" = "访问密钥 ID"; +"Add Account" = "添加账号"; +"Adding Account…" = "正在添加账号…"; +"Antigravity login failed" = "Antigravity 登录失败"; +"Antigravity login timed out" = "Antigravity 登录超时"; +"Auth source" = "认证来源"; +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "自动导入 Xiaomi MiMo 的 Chrome 浏览器 Cookie。"; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "自动从 Chromium 浏览器 localStorage 导入 Windsurf 会话数据。"; +"Automatic imports browser cookies from Bailian." = "自动导入 Bailian 的浏览器 Cookie。"; +"Automatically imports browser cookies." = "自动导入浏览器 Cookie。"; +"Automatically imports browser session cookies." = "自动导入浏览器会话 Cookie。"; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI 部署名称。也支持 AZURE_OPENAI_DEPLOYMENT_NAME。"; +"Azure OpenAI key" = "Azure OpenAI 密钥"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI 资源端点。也支持 AZURE_OPENAI_ENDPOINT。"; +"Base URL" = "Base URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "LLM-API-Key-Proxy 实例的 Base URL。"; +"Browser cookies" = "浏览器 Cookie"; +"Cap end" = "上限终点"; +"Cap start" = "上限起点"; +"Capacity End" = "容量终点"; +"Capacity Start" = "容量起点"; +"Changelog" = "变更记录"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "选择国际或中国大陆账号使用的 Moonshot/Kimi API 主机。"; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar 无法替换仅使用 API 密钥登录设置的系统账号。"; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar 找不到该账号已保存的认证。请重新认证后再试。"; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar 无法读取托管账号存储区。请先修复存储区,再添加其他账号。"; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar 无法读取该账号已保存的认证。请重新认证后再试。"; +"CodexBar could not read the current system account on this Mac." = "CodexBar 无法读取此 Mac 上当前的系统账号。"; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar 无法替换此 Mac 上当前的 Codex 认证。"; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar 无法在切换前安全保留当前的系统账号。"; +"CodexBar could not save the current system account before switching." = "CodexBar 无法在切换前保存当前的系统账号。"; +"CodexBar could not update managed account storage." = "CodexBar 无法更新托管账号存储区。"; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar 发现另一个托管账号已使用当前的系统账号。请先解决重复账号,再进行切换。"; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求“%@”,以解密浏览器 Cookie 并认证你的账号。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求 Claude Code OAuth token,以获取你的 Claude 用量。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 Amp Cookie 标头,以获取用量。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 Augment Cookie 标头,以获取用量。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 Claude Cookie 标头,以获取 Claude 网页用量。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 Cursor Cookie 标头,以获取用量。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 Factory Cookie 标头,以获取用量。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 GitHub Copilot token,以获取用量。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 Kimi K2 API 密钥,以获取用量。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 Kimi 认证 token,以获取用量。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 MiniMax API token,以获取用量。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 MiniMax Cookie 标头,以获取用量。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 OpenAI Cookie 标头,以获取 Codex 仪表盘额外数据。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 OpenCode Cookie 标头,以获取用量。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 Synthetic API 密钥,以获取用量。点击“确定”继续。"; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar 将向 macOS 钥匙串请求你的 z.ai API token,以获取用量。点击“确定”继续。"; +"Could not open Cursor login in your browser." = "无法在浏览器中打开 Cursor 登录。"; +"Could not open browser for Antigravity" = "无法为 Antigravity 打开浏览器"; +"Credits used" = "已用额度"; +"Day" = "日期"; +"Deployment" = "部署"; +"Drag to reorder" = "拖动以重新排序"; +"Endpoint" = "端点"; +"Enterprise host" = "Enterprise 主机"; +"Extra usage balance: %@" = "额外使用量余额:%@"; +"Keychain Access Required" = "需要钥匙串访问权限"; +"Kiro menu bar value" = "Kiro 菜单栏数值"; +"Label" = "标签"; +"No organizations loaded. Click Refresh after setting your API key." = "尚未加载组织。设置 API 密钥后点击“刷新”。"; +"No output captured." = "未捕获到输出。"; +"No system account" = "没有系统账号"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "打开 Augment(退出后重新登录)"; +"Open Codebuff Dashboard" = "打开 Codebuff 仪表盘"; +"Open Command Code Settings" = "打开 Command Code 设置"; +"Open Crof dashboard" = "打开 Crof 仪表盘"; +"Open Manus" = "打开 Manus"; +"Open MiMo Balance" = "打开 MiMo 余额"; +"Open Moonshot Console" = "打开 Moonshot 控制台"; +"Open Ollama API Keys" = "打开 Ollama API 密钥"; +"Open StepFun Platform" = "打开 StepFun 平台"; +"Open T3 Chat Settings" = "打开 T3 Chat 设置"; +"Open Volcengine Ark Console" = "打开 Volcengine Ark 控制台"; +"Open legacy provider docs" = "打开旧版提供商文档"; +"Open projects" = "打开项目"; +"Open this URL manually to continue login:\n\n%@" = "手动打开此 URL 以继续登录:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "适用于关联多个 Anthropic 组织的账号,可选填组织 ID。"; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "选填。应用到已设置的 Admin API 密钥;选中的 token 账号不会继承 OPENAI_PROJECT_ID。"; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "选填。输入你的 GitHub Enterprise 主机,例如 octocorp.ghe.com。留空则使用 github.com。"; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "选填。留空会发现并汇总 API 密钥可见的项目。"; +"Org ID (optional)" = "组织 ID(选填)"; +"Organizations" = "组织"; +"Password" = "密码"; +"%@ authentication is disabled." = "%@ 认证已禁用。"; +"%@ cookies are disabled." = "%@ Cookie 已禁用。"; +"%@ web API access is disabled." = "%@ Web API 访问已禁用。"; +"Disable %@ dashboard cookie usage." = "禁用 %@ 仪表盘 Cookie 用法。"; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "高级设置中已禁用钥匙串访问,因此无法导入浏览器 Cookie。"; +"Manually paste an %@ from a browser session." = "从浏览器会话中手动粘贴 %@。"; +"Paste a Cookie header captured from %@." = "粘贴从 %@ 捕获的 Cookie 标头。"; +"Paste a Cookie header from %@." = "粘贴来自 %@ 的 Cookie 标头。"; +"Paste a Cookie header or cURL capture from %@." = "粘贴来自 %@ 的 Cookie 标头或 cURL 捕获内容。"; +"Paste a Cookie header or full cURL capture from %@." = "粘贴来自 %@ 的 Cookie 标头或完整 cURL 捕获内容。"; +"Paste a Cookie or Authorization header from %@." = "粘贴来自 %@ 的 Cookie 或 Authorization 标头。"; +"Paste a full cookie header or the %@ value." = "粘贴完整 Cookie 标头或 %@ 值。"; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "粘贴 T3 Chat 设置中的 Cookie 标头或完整 cURL 捕获内容。"; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "粘贴发往 admin.mistral.ai 请求中的 Cookie 标头。必须包含 ory_session_* Cookie。"; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "粘贴 platform.stepfun.com 已登录浏览器会话中的 Oasis-Token。"; +"Paste the %@ JSON bundle from %@." = "粘贴来自 %2$@ 的 %1$@ JSON 包。"; +"Paste the %@ value or a full Cookie header." = "粘贴 %@ 值或完整 Cookie 标头。"; +"Personal account" = "个人账号"; +"Project ID" = "项目 ID"; +"Re-auth" = "重新认证"; +"Re-authenticating…" = "正在重新认证…"; +"Refresh Session" = "刷新会话"; +"Refresh organizations" = "刷新组织"; +"Region" = "区域"; +"Reload" = "重新加载"; +"Reorder" = "重新排序"; +"Secret access key" = "秘密访问密钥"; +"Series" = "序列"; +"Service" = "服务"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "在菜单栏图标旁显示或隐藏 Kiro 额度、百分比,或两者都显示。"; +"Show usage for organizations you belong to. Personal account is always shown." = "显示你所属组织的用量。个人账号始终显示。"; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "请在浏览器中登录 cursor.com,然后在 CodexBar 刷新 Cursor。"; +"Simulated error text" = "模拟错误文字"; +"StepFun platform account (phone number or email)." = "StepFun 平台账号(手机号或电子邮件)。"; +"Stored in ~/.codexbar/config.json." = "存储在 ~/.codexbar/config.json 中。"; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "存储在 ~/.codexbar/config.json 中。也支持 AZURE_OPENAI_API_KEY。"; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "存储在 ~/.codexbar/config.json 中。官方 Kimi API 请使用 Moonshot / Kimi API。"; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "存储在 ~/.codexbar/config.json 中。请从 Volcengine Ark 控制台获取 API 密钥。"; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "存储在 ~/.codexbar/config.json 中。请从 Ollama 设置获取密钥。"; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "存储在 ~/.codexbar/config.json 中。请从 console.deepgram.com 获取密钥。"; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "存储在 ~/.codexbar/config.json 中。请从 elevenlabs.io/app/settings/api-keys 获取密钥。"; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "存储在 ~/.codexbar/config.json 中。请从 openrouter.ai/settings/keys 获取密钥,并在那里设置密钥支出上限以启用 API 密钥配额跟踪。"; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "存储在 ~/.codexbar/config.json 中。在 Warp 中打开 Settings > Platform > API Keys,然后创建一个。"; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "存储在 ~/.codexbar/config.json 中。指标需要 Groq Enterprise Prometheus 访问权限。"; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "存储在 ~/.codexbar/config.json 中。优先使用 OPENAI_ADMIN_KEY;OPENAI_API_KEY 仍可使用。"; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "存储在 ~/.codexbar/config.json 中。需要 Anthropic Admin API 密钥。"; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "存储在 ~/.codexbar/config.json 中。用于 /v1/quota-stats。"; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "存储在 ~/.codexbar/config.json 中。你也可以提供 CODEBUFF_API_KEY,或让 CodexBar 读取由 `codebuff login` 创建的 ~/.config/manicode/credentials.json。"; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "存储在 ~/.codexbar/config.json 中。你也可以提供 CROF_API_KEY。"; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "存储在 ~/.codexbar/config.json 中。你也可以提供 KILO_API_KEY 或 ~/.local/share/kilo/auth.json(kilo.access)。"; +"T3 Chat cookie" = "T3 Chat Cookie"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "该账号已无法在 CodexBar 中使用。请刷新账号列表后再试。"; +"The browser login did not complete in time. Try Antigravity login again." = "浏览器登录未在时限内完成。请再次尝试 Antigravity 登录。"; +"Timed out waiting for Cursor login. %@" = "等待 Cursor 登录超时。%@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "等待 Cursor 登录超时。%@ 最后错误:%@"; +"Today requests" = "今日请求"; +"Total (30d): %@ credits" = "总计(30 天):%@ 额度"; +"Username" = "用户名"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "使用用户名与密码登录,并自动获取 Oasis-Token。"; +"Uses username + password to login and obtain an %@ automatically." = "使用用户名与密码登录,并自动获取 %@。"; +"Utilization End" = "使用率终点"; +"Utilization Start" = "使用率起点"; +"Verbosity" = "详细程度"; +"Windsurf session JSON bundle" = "Windsurf 会话 JSON 包"; +"Workspace ID" = "工作区 ID"; +"Your StepFun platform password. Used to login and obtain a session token." = "你的 StepFun 平台密码。用于登录并获取会话 token。"; +"claude /login exited with status %d." = "claude /login 以状态 %d 结束。"; +"codex login exited with status %d." = "codex login 以状态 %d 结束。"; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\n或粘贴 Abacus AI 仪表盘的 cURL 捕获内容"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\n或粘贴 __Secure-next-auth.session-token 值"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\n或粘贴 kimi-auth token 值"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\n或只粘贴 session_id 值"; +"Clear" = "清除"; +"No matching providers" = "没有匹配的提供商"; +"Search providers" = "搜索提供商"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings new file mode 100644 index 00000000..46034fcf --- /dev/null +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,931 @@ +/* Chinese (Traditional) localization for CodexBar */ + +" providers" = " 提供者"; +"(System)" = "(System)"; +"30d" = "30 天"; +"A managed Codex login is already running. Wait for it to finish before adding " = "託管 Codex 登入已在執行。請等待其完成後再新增 "; +"API key" = "API 金鑰"; +"API key limit" = "API 金鑰限制"; +"API region" = "API 區域"; +"API token" = "API token"; +"API tokens" = "API token"; +"About" = "關於"; +"Account" = "帳號"; +"Accounts" = "帳號"; +"Accounts subtitle" = "帳號副標題"; +"Active" = "作用中"; +"Add" = "新增"; +"Add Workspace" = "新增工作區"; +"Advanced" = "進階"; +"All" = "全部"; +"Always allow prompts" = "一律允許提示"; +"Animation pattern" = "動畫模式"; +"Antigravity login is managed in the app" = "Antigravity 登入由 App 管理"; +"Applies only to the Security.framework OAuth keychain reader." = "僅適用於 Security.framework OAuth 鑰匙圈讀取器。"; +"Auth" = "認證"; +"Auto" = "自動"; +"Auto falls back to the next source if the preferred one fails." = "如果偏好的來源失敗,自動改用下一個來源。"; +"Auto uses API first, then falls back to CLI on auth failures." = "自動優先使用 API,認證失敗時改用 CLI。"; +"Auto-detect" = "自動偵測"; +"Auto-refresh is off; use the menu's Refresh command." = "自動重新整理已關閉;請使用選單中的「重新整理」指令。"; +"Auto-refresh: hourly · Timeout: 10m" = "自動重新整理:每小時 · 逾時:10 分鐘"; +"Automatic" = "自動"; +"Automatic imports browser cookies and WorkOS tokens." = "自動匯入瀏覽器 Cookie 和 WorkOS token。"; +"Automatic imports browser cookies and local storage tokens." = "自動匯入瀏覽器 Cookie 和本機儲存 token。"; +"Automatic imports browser cookies for dashboard extras." = "自動匯入用於儀表板附加功能的瀏覽器 Cookie。"; +"Automatic imports browser cookies for the web API." = "自動匯入用於 Web API 的瀏覽器 Cookie。"; +"Automatic imports browser cookies from Model Studio/Bailian." = "自動從 Model Studio/Bailian 匯入瀏覽器 Cookie。"; +"Automatic imports browser cookies from admin.mistral.ai." = "自動從 admin.mistral.ai 匯入瀏覽器 Cookie。"; +"Automatic imports browser cookies from opencode.ai." = "自動從 opencode.ai 匯入瀏覽器 Cookie。"; +"Automatic imports browser cookies or stored sessions." = "自動匯入瀏覽器 Cookie 或已儲存的工作階段。"; +"Automatic imports browser cookies." = "自動匯入瀏覽器 Cookie。"; +"Automatically imports browser session cookie." = "自動匯入瀏覽器工作階段 Cookie。"; +"Automatically opens CodexBar when you start your Mac." = "登入 Mac 時自動開啟 CodexBar。"; +"Automation" = "自動化"; +"Average (\\(label1) + \\(label2))" = "平均(\\(label1) + \\(label2))"; +"Average (\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))" = "平均(\\(metadata.sessionLabel) + \\(metadata.weeklyLabel))"; +"Avoid Keychain prompts" = "避免鑰匙圈提示"; +"Balance" = "餘額"; +"Battery Saver" = "省電模式"; +"Bordered" = "有邊框"; +"Build" = "建置"; +"Built \\(buildTimestamp)" = "建置於 \\(buildTimestamp)"; +"Buy Credits..." = "購買額度..."; +"Buy Credits…" = "購買額度…"; +"CLI paths" = "CLI 路徑"; +"CLI sessions" = "CLI 工作階段"; +"Caches" = "快取"; +"Cancel" = "取消"; +"Check for Updates…" = "檢查更新…"; +"Check for updates automatically" = "自動檢查更新"; +"Check if you like your agents having some fun up there." = "讓選單列上的 Agent 多一點變化。"; +"Check provider status" = "檢查提供者狀態"; +"Choose Codex workspace" = "選擇 Codex 工作區"; +"Choose the MiniMax host (global .io or China mainland .com)." = "選擇 MiniMax 主機(全球 .io 或中國大陸 .com)。"; +"Choose up to " = "選擇最多 "; +"Choose up to \\(Self.maxOverviewProviders) providers" = "選擇最多 \\(Self.maxOverviewProviders) 個提供者"; +"Choose up to \\(count) providers" = "選擇最多 \\(count) 個提供者"; +"Choose what to show in the menu bar (Pace shows usage vs. expected)." = "選擇選單列中顯示的內容(進度會比較實際與預期使用量)。"; +"Choose which Codex account CodexBar should follow." = "選擇 CodexBar 要追蹤的 Codex 帳號。"; +"Choose which window drives the menu bar percent." = "選擇用於驅動選單列百分比的時段。"; +"Chrome" = "Chrome"; +"Claude CLI not found" = "找不到 Claude CLI"; +"Claude binary" = "Claude 二進位檔案"; +"Claude cookies" = "Claude Cookie"; +"Claude login failed" = "Claude 登入失敗"; +"Claude login timed out" = "Claude 登入逾時"; +"Close" = "關閉"; +"Code review" = "程式碼審查"; +"Codex CLI not found" = "找不到 Codex CLI"; +"Codex account login already running" = "Codex 帳號登入已在執行"; +"Codex binary" = "Codex 二進位檔案"; +"Codex login failed" = "Codex 登入失敗"; +"Codex login timed out" = "Codex 登入逾時"; +"CodexBar Lifecycle Keepalive" = "CodexBar 生命週期維持"; +"CodexBar could not read managed account storage. " = "CodexBar 無法讀取託管帳號儲存。"; +"Configure…" = "設定…"; +"Connected" = "已連線"; +"Controls how much detail is logged." = "控制記錄詳細程度。"; +"Cookie header" = "Cookie 標頭"; +"Cookie source" = "Cookie 來源"; +"Cookie: ..." = "Cookie:..."; +"Cookie: \\u{2026}\\\n\\\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie:\\u{2026}\\\n\\\n或貼上來自 Abacus AI 儀表板的 cURL 擷取內容"; +"Cookie: \\u{2026}\\\n\\\nor paste the __Secure-next-auth.session-token value" = "Cookie:\\u{2026}\\\n\\\n或貼上 __Secure-next-auth.session-token 的值"; +"Cookie: \\u{2026}\\\n\\\nor paste the kimi-auth token value" = "Cookie:\\u{2026}\\\n\\\n或貼上 kimi-auth token 值"; +"Cookie: …" = "Cookie:…"; +"CopilotDeviceFlow" = "Copilot 裝置流程"; +"Cost" = "費用"; +"Could not add Codex account" = "無法新增 Codex 帳號"; +"Could not open Terminal for Gemini" = "無法為 Gemini 開啟終端"; +"Could not start claude /login" = "無法啟動 claude /login"; +"Could not start codex login" = "無法啟動 codex login"; +"Could not switch system account" = "無法切換系統帳號"; +"Credits" = "額度"; +"Credits history" = "額度歷史"; +"Cursor login failed" = "Cursor 登入失敗"; +"Custom" = "自訂"; +"Custom Path" = "自訂路徑"; +"Daily Routines" = "每日例行工作"; +"Debug" = "除錯"; +"Default" = "預設"; +"Disable Keychain access" = "停用鑰匙圈存取"; +"Disabled" = "已停用"; +"Disabled — no recent data" = "已停用 — 無近期資料"; +"Disconnected" = "已中斷連線"; +"Display" = "顯示"; +"Display mode" = "顯示模式"; +"Display reset times as absolute clock values instead of countdowns." = "將重置時間顯示為絕對時鐘值,而不是倒數計時。"; +"Done" = "完成"; +"Effective PATH" = "有效 PATH"; +"Email" = "電子郵件"; +"Enable Merge Icons to configure Overview tab providers." = "啟用「合併圖示」以設定「概覽」標籤中的提供者。"; +"Enable file logging" = "啟用檔案記錄"; +"Enabled" = "已啟用"; +"Error" = "錯誤"; +"Error simulation" = "錯誤模擬"; +"Expose troubleshooting tools in the Debug tab." = "在「除錯」標籤中顯示疑難排解工具。"; +"Failed" = "失敗"; +"False" = "假"; +"Fetch strategy attempts" = "取得策略嘗試"; +"Fetching" = "取得中"; +"Field" = "欄位"; +"Field subtitle" = "欄位副標題"; +"Finish the current managed account change before switching the system account." = "請先完成目前託管帳號變更,再切換系統帳號。"; +"Force animation on next refresh" = "下次重新整理時強制動畫"; +"Gateway region" = "閘道區域"; +"Gemini CLI not found" = "找不到 Gemini CLI"; +"Gemini/Antigravity, surfacing incidents in the icon and menu." = "Gemini/Antigravity,並在圖示和選單中顯示服務異常。"; +"General" = "一般"; +"GitHub" = "GitHub"; +"GitHub Copilot Login" = "GitHub Copilot 登入"; +"GitHub Login" = "GitHub 登入"; +"Hide details" = "隱藏詳細資訊"; +"Hide personal information" = "隱藏個人資訊"; +"Historical tracking" = "歷史追蹤"; +"How often CodexBar polls providers in the background." = "CodexBar 在背景輪詢提供者的頻率。"; +"Inactive" = "非作用中"; +"Install CLI" = "安裝 CLI"; +"Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again." = "安裝 Claude CLI(npm i -g @anthropic-ai/claude-code)後重試。"; +"Install the Codex CLI (npm i -g @openai/codex) and try again." = "安裝 Codex CLI(npm i -g @openai/codex)後重試。"; +"Install the Gemini CLI (npm i -g @google/gemini-cli) and try again." = "安裝 Gemini CLI(npm i -g @google/gemini-cli)後重試。"; +"JetBrains AI is ready" = "JetBrains AI 已就緒"; +"JetBrains IDE" = "JetBrains IDE"; +"Keep CLI sessions alive" = "保持 CLI 工作階段存活"; +"Keyboard shortcut" = "快速鍵"; +"Keychain access" = "鑰匙圈存取"; +"Keychain prompt policy" = "鑰匙圈提示策略"; +"Last \\(name) fetch failed:" = "上次取得 \\(name) 失敗:"; +"Last \\(self.store.metadata(for: self.provider).displayName) fetch failed:" = "上次取得 \\(self.store.metadata(for: self.provider).displayName) 失敗:"; +"Last attempt" = "上次嘗試"; +"Limits not available" = "無法取得限制資料"; +"Link" = "連結"; +"Loading animations" = "載入動畫"; +"Loading…" = "載入中…"; +"Local" = "本機"; +"Logging" = "記錄"; +"Login failed" = "登入失敗"; +"Login shell PATH (startup capture)" = "登入 shell PATH(啟動時擷取)"; +"Login timed out" = "登入逾時"; +"MCP details" = "MCP 詳細資訊"; +"Managed Codex accounts unavailable" = "無法使用託管 Codex 帳號"; +"Managed account storage is unreadable. Live account access is still available, " = "託管帳號儲存區無法讀取。即時帳號仍可存取,"; +"Manual" = "手動"; +"May your tokens never run out—keep agent limits in view." = "願你的 token 永不用完,隨時掌握 Agent 限制。"; +"Menu bar" = "選單列"; +"Menu bar auto-shows the provider closest to its rate limit." = "選單列會自動顯示最接近速率限制的提供者。"; +"Menu bar metric" = "選單列指標"; +"Menu bar shows percent" = "選單列顯示百分比"; +"Menu content" = "選單內容"; +"Merge Icons" = "合併圖示"; +"Never prompt" = "永不提示"; +"No" = "否"; +"No Codex accounts detected yet." = "未偵測到 Codex 帳號。"; +"No JetBrains IDE detected" = "未偵測到 JetBrains IDE"; +"No cost history data." = "尚無費用歷史資料。"; +"No usage yet" = "尚無使用量"; +"Not fetched yet" = "尚未取得"; +"No credits history data." = "尚無額度歷史資料。"; +"No data available" = "沒有可用資料"; +"No data yet" = "尚無資料"; +"No enabled providers available for Overview." = "「概覽」中沒有可用的已啟用提供者。"; +"No providers selected" = "未選擇提供者"; +"No token accounts yet." = "尚無 token 帳號。"; +"No usage breakdown data." = "尚無使用量明細資料。"; +"None" = "無"; +"Notifications" = "通知"; +"Notifies when the 5-hour session quota hits 0% and when it becomes " = "當 5 小時工作階段配額降至 0% 或"; +"OK" = "好"; +"Obscure email addresses in the menu bar and menu UI." = "在選單列和選單介面中隱藏電子郵件地址。"; +"Off" = "關閉"; +"Offline" = "離線"; +"On" = "開啟"; +"Online" = "線上"; +"Only on user action" = "僅在使用者操作時"; +"Open" = "開啟"; +"Open API Keys" = "開啟 API 金鑰"; +"Open Amp Settings" = "開啟 Amp 設定"; +"Open Antigravity to sign in, then refresh CodexBar." = "開啟 Antigravity 登入,然後重新整理 CodexBar。"; +"Open Browser" = "開啟瀏覽器"; +"Open Coding Plan" = "開啟 Coding Plan"; +"Open Console" = "開啟主控台"; +"Open Dashboard" = "開啟儀表板"; +"Open Mistral Admin" = "開啟 Mistral 管理頁面"; +"Open Ollama Settings" = "開啟 Ollama 設定"; +"Open Terminal" = "開啟終端"; +"Open Usage Page" = "開啟使用量頁面"; +"Open Warp API Key Guide" = "開啟 Warp API 金鑰指南"; +"Open menu" = "開啟選單"; +"Open token file" = "開啟 token 檔案"; +"OpenAI cookies" = "OpenAI Cookie"; +"OpenAI web extras" = "OpenAI Web 附加功能"; +"Option A" = "選項 A"; +"Option B" = "選項 B"; +"Optional override if workspace lookup fails." = "找不到工作區時可選的覆寫值。"; +"Options" = "選項"; +"Override auto-detection with a custom IDE base path" = "使用自訂 IDE 基礎路徑覆蓋自動偵測"; +"Overview" = "概覽"; +"Overview rows always follow provider order." = "概覽列一律依提供者順序排列。"; +"Overview tab providers" = "概覽標籤提供者"; +"Paste API key…" = "貼上 API 金鑰…"; +"Paste API token…" = "貼上 API token…"; +"Paste key…" = "貼上金鑰…"; +"Paste sessionKey or OAuth token…" = "貼上 sessionKey 或 OAuth token…"; +"Paste the Cookie header from a request to admin.mistral.ai. " = "貼上發往 admin.mistral.ai 請求中的 Cookie 標頭。"; +"Paste token…" = "貼上 token…"; +"Personal" = "個人"; +"Picker" = "選擇器"; +"Picker subtitle" = "選擇器副標題"; +"Placeholder" = "預留位置"; +"Plan" = "方案"; +"Play full-screen confetti when weekly usage resets." = "每週使用量重置時播放全螢幕慶祝動畫。"; +"Polls OpenAI/Claude status pages and Google Workspace for " = "輪詢 OpenAI/Claude 狀態頁面和 Google Workspace,以檢查"; +"Prevents any Keychain access while enabled." = "啟用時封鎖任何鑰匙圈存取。"; +"Primary (API key limit)" = "主要(API 金鑰限制)"; +"Primary (\\(label))" = "主要(\\(label))"; +"Primary (\\(metadata.sessionLabel))" = "主要(\\(metadata.sessionLabel))"; +"Probe logs" = "探測記錄"; +"Progress bars fill as you consume quota (instead of showing remaining)." = "進度條會隨配額消耗而填滿(而不是顯示剩餘量)。"; +"Provider" = "提供者"; +"Providers" = "提供者"; +"Quit CodexBar" = "結束 CodexBar"; +"Random (default)" = "隨機(預設)"; +"Reads local usage logs. Shows today + last 30 days cost in the menu." = "讀取本機使用量記錄。在選單中顯示今天及所選歷史時段的費用。"; +"Refresh" = "重新整理"; +"Refreshing" = "正在重新整理"; +"Refresh cadence" = "重新整理頻率"; +"Remote" = "遠端"; +"Remove" = "移除"; +"Remove Codex account?" = "移除 Codex 帳號?"; +"Remove \\(account.email) from CodexBar? Its managed Codex home will be deleted." = "要從 CodexBar 中移除 \\(account.email) 嗎?其託管的 Codex 主目錄將被刪除。"; +"Remove \\(email) from CodexBar? Its managed Codex home will be deleted." = "要從 CodexBar 中移除 \\(email) 嗎?其託管的 Codex 主目錄將被刪除。"; +"Remove selected account" = "移除所選帳號"; +"Replace critter bars with provider branding icons and a percentage." = "將小動物進度條替換為提供者品牌圖示和百分比。"; +"Replay selected animation" = "重播選取的動畫"; +"Requires authentication via GitHub Device Flow." = "需要透過 GitHub 裝置流程進行認證。"; +"Resets: \\(reset)" = "重置:\\(reset)"; +"Rolling five-hour limit" = "滾動式 5 小時限制"; +"Search hourly" = "每小時搜尋"; +"Secondary (\\(label))" = "次要(\\(label))"; +"Secondary (\\(metadata.weeklyLabel))" = "次要(\\(metadata.weeklyLabel))"; +"Select a provider" = "選擇提供者"; +"Select the IDE to monitor" = "選擇要監控的 IDE"; +"Session" = "工作階段"; +"Session quota notifications" = "工作階段配額通知"; +"Session tokens" = "工作階段 token"; +"Settings" = "設定"; +"Show Codex Credits and Claude Extra usage sections in the menu." = "在選單中顯示 Codex 額度和 Claude 額外使用量部分。"; +"Show Debug Settings" = "顯示除錯設定"; +"Show all token accounts" = "顯示所有 token 帳號"; +"Show cost summary" = "顯示費用摘要"; +"Show credits + extra usage" = "顯示額度 + 額外使用量"; +"Show details" = "顯示詳細資訊"; +"Show most-used provider" = "顯示使用量最高的提供者"; +"Show provider icons in the switcher (otherwise show a weekly progress line)." = "在切換器中顯示提供者圖示(否則顯示每週進度線)。"; +"Show reset time as clock" = "以時鐘時間顯示重置時間"; +"Show usage as used" = "以已用量顯示"; +"Sign in via button below" = "透過下方按鈕登入"; +"Skip teardown between probes (debug-only)." = "探測之間跳過清理(僅限除錯)。"; +"Source" = "來源"; +"Stack token accounts in the menu (otherwise show an account switcher bar)." = "在選單中堆疊 token 帳號(否則顯示帳號切換欄)。"; +"Start at Login" = "登入時啟動"; +"State" = "狀態"; +"Status" = "狀態"; +"Store Claude sessionKey cookies or OAuth access tokens." = "儲存 Claude sessionKey Cookie 或 OAuth 存取 token。"; +"Store multiple Abacus AI Cookie headers." = "儲存多個 Abacus AI Cookie 標頭。"; +"Store multiple Augment Cookie headers." = "儲存多個 Augment Cookie 標頭。"; +"Store multiple Cursor Cookie headers." = "儲存多個 Cursor Cookie 標頭。"; +"Store multiple Factory Cookie headers." = "儲存多個 Factory Cookie 標頭。"; +"Store multiple MiniMax Cookie headers." = "儲存多個 MiniMax Cookie 標頭。"; +"Store multiple Mistral Cookie headers." = "儲存多個 Mistral Cookie 標頭。"; +"Store multiple Ollama Cookie headers." = "儲存多個 Ollama Cookie 標頭。"; +"Store multiple OpenCode Cookie headers." = "儲存多個 OpenCode Cookie 標頭。"; +"Store multiple OpenCode Go Cookie headers." = "儲存多個 OpenCode Go Cookie 標頭。"; +"Stored in the CodexBar config file." = "儲存在 CodexBar 設定檔中。"; +"Stored in ~/.codexbar/config.json. " = "儲存在 ~/.codexbar/config.json 中。"; +"Stored in ~/.codexbar/config.json. Generate one at kimi-k2.ai." = "儲存在 ~/.codexbar/config.json 中。可在 kimi-k2.ai 產生。"; +"Stored in ~/.codexbar/config.json. Paste the key from the Synthetic dashboard." = "儲存在 ~/.codexbar/config.json 中。請貼上來自 Synthetic 儀表板的金鑰。"; +"Stored in ~/.codexbar/config.json. Paste your Coding Plan API key from Model Studio." = "儲存在 ~/.codexbar/config.json 中。請貼上來自 Model Studio 的 Coding Plan API 金鑰。"; +"Stored in ~/.codexbar/config.json. Paste your MiniMax API key." = "儲存在 ~/.codexbar/config.json 中。請貼上你的 MiniMax API 金鑰。"; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or " = "儲存在 ~/.codexbar/config.json 中。你也可以提供 KILO_API_KEY 或"; +"Stores local Codex usage history (8 weeks) to personalize Pace predictions." = "儲存本機 Codex 使用量歷史(8 週),用於個人化進度預測。"; +"Subscription Utilization" = "訂閱使用率"; +"Surprise me" = "給我驚喜"; +"Switcher shows icons" = "切換器顯示圖示"; +"Symlink CodexBarCLI to /usr/local/bin and /opt/homebrew/bin as codexbar." = "將 CodexBarCLI 作為 codexbar 符號連結到 /usr/local/bin 和 /opt/homebrew/bin。"; +"System" = "系統"; +"Temporarily shows the loading animation after the next refresh." = "下次重新整理後暫時顯示載入動畫。"; +"Tertiary (\\(label))" = "第三(\\(label))"; +"Tertiary (\\(tertiaryTitle))" = "第三(\\(tertiaryTitle))"; +"The default Codex account on this Mac." = "此 Mac 上的預設 Codex 帳號。"; +"Toggle" = "切換"; +"Toggle subtitle" = "切換副標題"; +"Token" = "token"; +"Trigger the menu bar menu from anywhere." = "可從任何位置開啟選單列選單。"; +"True" = "真"; +"Twitter" = "Twitter"; +"Unsupported" = "不支援"; +"Unavailable" = "無法使用"; +"Update Channel" = "更新頻道"; +"Updated" = "已更新"; +"Updates unavailable in this build." = "此建置無法使用更新功能。"; +"Usage" = "使用量"; +"Usage breakdown" = "使用量明細"; +"Usage history (30 days)" = "使用量歷史"; +"Usage source" = "使用量來源"; +"Use BigModel for the China mainland endpoints (open.bigmodel.cn)." = "中國大陸端點使用 BigModel(open.bigmodel.cn)。"; +"Use a single menu bar icon with a provider switcher." = "使用單一選單列圖示並帶提供者切換器。"; +"Use international or China mainland console gateways for quota fetches." = "使用國際或中國大陸主控台閘道取得配額資料。"; +"Version" = "版本"; +"Version \\(self.versionString)" = "版本 \\(self.versionString)"; +"Version \\(version)" = "版本 \\(version)"; +"Version \\(versionString)" = "版本 \\(versionString)"; +"Vertex AI Login" = "Vertex AI 登入"; +"Wait for the current managed Codex login to finish before adding another account." = "請等待目前託管 Codex 登入完成後再新增其他帳號。"; +"Waiting for Authentication..." = "等待認證…"; +"Website" = "網站"; +"Weekly" = "每週"; +"Weekly limit confetti" = "每週重置慶祝動畫"; +"Weekly token limit" = "每週 token 限制"; +"Weekly usage" = "每週使用量"; +"Weekly usage unavailable for this account." = "此帳號無法取得每週使用量。"; +"Window: \\(window)" = "時段:\\(window)"; +"Write logs to \\(self.fileLogPath) for debugging." = "將記錄寫入 \\(self.fileLogPath) 以進行除錯。"; +"Yes" = "是"; +"not detected" = "未偵測到"; +"\\(detail.modelCode): \\(usage)" = "\\(detail.modelCode):\\(usage)"; +"\\(name): \\(truncated)" = "\\(name):\\(truncated)"; +"\\(name): \\(updated) · 30d \\(cost)" = "\\(name):\\(updated) · 30 天 \\(cost)"; +"\\(name): fetching…\\(elapsed)" = "\\(name):取得中…\\(elapsed)"; +"\\(name): last attempt \\(when)" = "\\(name):上次嘗試 \\(when)"; +"\\(name): no data yet" = "\\(name):尚無資料"; +"\\(name): unsupported" = "\\(name):不支援"; +"all browsers" = "所有瀏覽器"; +"available again." = "恢復可用時傳送通知。"; +"built_format" = "建置於 %@"; +"copilot_complete_in_browser" = "請在瀏覽器中完成登入。"; +"copilot_device_code_copied" = "裝置代碼已複製。"; +"copilot_verify_at" = "請在 %@ 驗證"; +"copilot_window_closes_auto" = "登入完成後,此視窗會自動關閉。"; +"cost_status_error" = "%1$@:%2$@"; +"cost_status_fetching" = "%1$@:取得中… %2$@"; +"cost_status_last_attempt" = "%1$@:上次嘗試 %2$@"; +"cost_status_no_data" = "%@:尚無資料"; +"cost_status_snapshot" = "%1$@:%2$@ · %3$@ %4$@"; +"cost_status_unsupported" = "%@:不支援"; +"credits_remaining" = "額度:%@"; +"cursor_on_demand" = "隨用隨付:%@"; +"cursor_on_demand_with_limit" = "隨用隨付:%1$@ / %2$@"; +"extra_usage_format" = "額外使用量:%1$@ / %2$@"; +"jetbrains_detected_generate" = "偵測到:%@。使用一次 AI 助手以產生配額資料,然後重新整理 CodexBar。"; +"jetbrains_detected_select" = "偵測到:%@。在設定中選擇你偏好的 IDE,然後重新整理 CodexBar。"; +"last_fetch_failed_with_provider" = "上次取得 %@ 失敗:"; +"last_spend" = "上次支出:%@"; +"mcp_model_usage" = "%1$@:%2$@"; +"mcp_resets" = "重置:%@"; +"mcp_window" = "時段:%@"; +"metric_average" = "平均(%1$@ + %2$@)"; +"metric_primary" = "主要(%@)"; +"metric_secondary" = "次要(%@)"; +"metric_tertiary" = "第三(%@)"; +"multiple_workspaces_found" = "CodexBar 發現 %@ 有多個工作區。請選擇要新增的工作區。"; +"ory_session_…=…; csrftoken=…" = "ory_session_…=…; csrftoken=…"; +"overview_choose_providers" = "最多選擇 %@ 個提供者"; +"remove_account_message" = "要從 CodexBar 中移除 %@ 嗎?其託管的 Codex 主目錄將被刪除。"; +"version_format" = "版本 %@"; +"workspaceID is set but only opencode, opencodego, and deepgram support workspaceID." = "已設定 workspaceID,但只有 opencode、opencodego 和 deepgram 支援 workspaceID。"; +"© 2026 Peter Steinberger. MIT License." = "© 2026 Peter Steinberger。MIT 許可證。"; +"section_system" = "系統"; +"section_usage" = "使用量"; +"section_automation" = "自動化"; +"language_title" = "語言"; +"language_subtitle" = "更改顯示語言。需要重新啟動 App 才會完全生效。"; +"language_system" = "依照系統"; +"language_english" = "English"; +"language_spanish" = "Español"; +"language_catalan" = "Català"; +"language_chinese_simplified" = "简体中文"; +"language_chinese_traditional" = "繁體中文"; +"language_portuguese_brazilian" = "Português (Brasil)"; +"start_at_login_title" = "登入時啟動"; +"start_at_login_subtitle" = "登入 Mac 時自動開啟 CodexBar。"; +"show_cost_summary" = "顯示費用摘要"; +"show_cost_summary_subtitle" = "讀取本機使用量記錄。在選單中顯示今天及所選歷史時段的費用。"; +"cost_history_days_title" = "歷史時段:%d 天"; +"cost_auto_refresh_info" = "自動重新整理:每小時 · 逾時:10 分鐘"; +"refresh_cadence_title" = "重新整理頻率"; +"refresh_cadence_subtitle" = "CodexBar 在背景輪詢提供者的頻率。"; +"manual_refresh_hint" = "自動重新整理已關閉;請使用選單中的「重新整理」指令。"; +"check_provider_status_title" = "檢查提供者狀態"; +"check_provider_status_subtitle" = "輪詢 OpenAI/Claude 狀態頁面和 Google Workspace 的 Gemini/Antigravity,並在圖示和選單中顯示服務異常資訊。"; +"session_quota_notifications_title" = "工作階段配額通知"; +"session_quota_notifications_subtitle" = "當 5 小時工作階段配額用完或恢復可用時傳送通知。"; +"quota_warning_notifications_title" = "配額提醒通知"; +"quota_warning_notifications_subtitle" = "當工作階段或每週剩餘配額達到設定門檻時提醒。"; +"quota_warnings_title" = "配額提醒"; +"quota_warning_session" = "工作階段"; +"quota_warning_session_capitalized" = "工作階段"; +"quota_warning_weekly" = "每週"; +"quota_warning_weekly_capitalized" = "每週"; +"quota_warning_notification_title" = "%1$@ %2$@配額偏低"; +"quota_warning_notification_body" = "剩餘 %1$@。已達到 %2$d%% %3$@提醒門檻。"; +"quota_warning_notification_body_with_account" = "帳號 %1$@。剩餘 %2$@。已達到 %3$d%% %4$@提醒門檻。"; +"session_depleted_notification_title" = "%@ 工作階段已用完"; +"session_depleted_notification_body" = "剩餘 0%。恢復可用時會再通知。"; +"session_restored_notification_title" = "%@ 工作階段已恢復"; +"session_restored_notification_body" = "工作階段配額已恢復可用。"; +"quota_warning_warn_at" = "提醒門檻"; +"quota_warning_global_threshold_subtitle" = "工作階段和每週時段的剩餘百分比,除非提供者另有設定。"; +"quota_warning_sound" = "播放通知音效"; +"quota_warning_provider_inherits" = "預設使用全域配額提醒設定,除非在此自訂時段。"; +"quota_warning_customize_thresholds" = "自訂 %@ 門檻"; +"quota_warning_enable_warnings" = "啟用 %@ 提醒"; +"quota_warning_window_warn_at" = "%@ 提醒門檻"; +"quota_warning_off" = "關閉"; +"quota_warning_inherited" = "繼承:%@"; +"quota_warning_depleted_only" = "僅用完時"; +"quota_warning_upper" = "上限"; +"quota_warning_lower" = "下限"; +"apply" = "套用"; +"quit_app" = "結束 CodexBar"; +"tab_general" = "一般"; +"tab_providers" = "提供者"; +"tab_display" = "顯示"; +"tab_advanced" = "進階"; +"tab_about" = "關於"; +"tab_debug" = "除錯"; +"select_a_provider" = "選擇提供者"; +"cancel" = "取消"; +"last_fetch_failed" = "上次取得失敗"; +"usage_not_fetched_yet" = "尚未取得使用量"; +"managed_account_storage_unreadable" = "託管帳號儲存區無法讀取。即時帳號仍可存取,但託管新增、重新認證和移除操作已被停用,直到儲存區恢復。"; +"remove_codex_account_title" = "移除 Codex 帳號?"; +"remove" = "移除"; +"managed_login_already_running" = "託管 Codex 登入已在執行。請等待完成後再新增或重新認證其他帳號。"; +"managed_login_failed" = "託管 Codex 登入未完成。請先在終端確認 `codex --version` 可以執行。如果 macOS 封鎖了 `codex` 或將它移到垃圾桶,請移除舊的重複安裝,執行 `npm install -g --include=optional @openai/codex@latest`,然後重試。"; +"codex_login_output" = "codex login 輸出:"; +"managed_login_missing_email" = "Codex 登入已完成,但無法取得帳號電子郵件。請在確認帳號已完全登入後重試。"; +"login_success_notification_title" = "%@ 登入成功"; +"login_success_notification_body" = "你可以回到 App;認證已完成。"; +"workspace_selection_cancelled" = "CodexBar 發現多個工作區,但未選擇任何工作區。"; +"unsafe_managed_home" = "CodexBar 拒絕修改意外的託管主目錄路徑:%@"; +"menu_bar_metric_title" = "選單列指標"; +"menu_bar_metric_subtitle" = "選擇哪個時段驅動選單列百分比。"; +"menu_bar_metric_subtitle_deepseek" = "在選單列顯示 DeepSeek 餘額。"; +"menu_bar_metric_subtitle_moonshot" = "在選單列顯示 Moonshot / Kimi API 餘額。"; +"menu_bar_metric_subtitle_mistral" = "在選單列顯示 Mistral API 本月支出。"; +"menu_bar_metric_subtitle_kimik2" = "在選單列顯示 Kimi K2 API 金鑰額度。"; +"automatic" = "自動"; +"primary_api_key_limit" = "主要(API 金鑰限制)"; +"section_menu_bar" = "選單列"; +"merge_icons_title" = "合併圖示"; +"merge_icons_subtitle" = "使用單一選單列圖示並帶提供者切換器。"; +"switcher_shows_icons_title" = "切換器顯示圖示"; +"switcher_shows_icons_subtitle" = "在切換器中顯示提供者圖示(否則顯示每週進度線)。"; +"show_most_used_provider_title" = "顯示使用量最高的提供者"; +"show_most_used_provider_subtitle" = "選單列會自動顯示最接近速率限制的提供者。"; +"menu_bar_shows_percent_title" = "選單列顯示百分比"; +"menu_bar_shows_percent_subtitle" = "將小動物進度條替換為提供者品牌圖示和百分比。"; +"display_mode_title" = "顯示模式"; +"display_mode_subtitle" = "選擇選單列中顯示的內容(進度會比較實際與預期使用量)。"; +"section_menu_content" = "選單內容"; +"show_usage_as_used_title" = "以已用量顯示"; +"show_usage_as_used_subtitle" = "進度條會隨配額消耗而填滿(而不是顯示剩餘量)。"; +"show_quota_warning_markers_title" = "顯示配額提醒標記"; +"show_quota_warning_markers_subtitle" = "設定配額提醒後,在使用量條上繪製門檻刻度標記。"; +"weekly_progress_work_days_title" = "每週進度工作日標記"; +"weekly_progress_work_days_subtitle" = "在每週使用量條上繪製日期邊界刻度標記。"; +"show_reset_time_as_clock_title" = "以時鐘時間顯示重置時間"; +"show_reset_time_as_clock_subtitle" = "將重置時間顯示為絕對時鐘值,而不是倒數計時。"; +"show_provider_changelog_links_title" = "顯示提供者版本資訊連結"; +"show_provider_changelog_links_subtitle" = "在選單中為支援的 CLI 提供者新增發行說明連結。"; +"show_credits_extra_usage_title" = "顯示額度 + 額外使用量"; +"show_credits_extra_usage_subtitle" = "在選單中顯示 Codex 額度和 Claude 額外使用量部分。"; +"show_all_token_accounts_title" = "顯示所有 token 帳號"; +"show_all_token_accounts_subtitle" = "在選單中堆疊 token 帳號(否則顯示帳號切換欄)。"; +"multi_account_layout_title" = "多帳號版面配置"; +"multi_account_layout_subtitle" = "選擇分段帳號切換或堆疊帳號卡片。"; +"multi_account_layout_segmented" = "分段"; +"multi_account_layout_stacked" = "堆疊"; +"overview_tab_providers_title" = "概覽標籤提供者"; +"configure" = "設定…"; +"overview_enable_merge_icons_hint" = "啟用「合併圖示」以設定「概覽」標籤中的提供者。"; +"overview_no_providers_hint" = "「概覽」中沒有可用的已啟用提供者。"; +"overview_rows_follow_order" = "概覽列一律依提供者順序排列。"; +"overview_no_providers_selected" = "未選擇提供者"; +"section_keyboard_shortcut" = "快速鍵"; +"open_menu_shortcut_title" = "開啟選單"; +"open_menu_shortcut_subtitle" = "從任意位置觸發選單列選單。"; +"install_cli" = "安裝 CLI"; +"install_cli_subtitle" = "將 CodexBarCLI 作為 codexbar 符號連結到 /usr/local/bin 和 /opt/homebrew/bin。"; +"cli_not_found" = "在 App 套件中找不到 CodexBarCLI。"; +"no_writable_bin_dirs" = "找不到可寫的 bin 目錄。"; +"show_debug_settings_title" = "顯示除錯設定"; +"show_debug_settings_subtitle" = "在「除錯」標籤中顯示疑難排解工具。"; +"surprise_me_title" = "給我驚喜"; +"surprise_me_subtitle" = "讓選單列上的 Agent 多一點變化。"; +"weekly_limit_confetti_title" = "每週重置慶祝動畫"; +"weekly_limit_confetti_subtitle" = "每週使用量重置時播放全螢幕慶祝動畫。"; +"hide_personal_info_title" = "隱藏個人資訊"; +"hide_personal_info_subtitle" = "在選單列和選單介面中隱藏電子郵件地址。"; +"show_provider_storage_usage_title" = "顯示提供者儲存使用量"; +"show_provider_storage_usage_subtitle" = "在選單中顯示本機磁碟使用量。會在背景掃描已知的提供者自有路徑。"; +"section_keychain_access" = "鑰匙圈存取"; +"keychain_access_caption" = "停用所有鑰匙圈讀寫。如果 macOS 在你按下一律允許後仍持續要求存取「Chrome/Brave/Edge Safe Storage」,可使用此選項。啟用時無法匯入瀏覽器 Cookie;請在「提供者」中手動貼上 Cookie 標頭。透過 CLI 的 Claude/Codex OAuth 仍可使用。"; +"disable_keychain_access_title" = "停用鑰匙圈存取"; +"disable_keychain_access_subtitle" = "啟用時封鎖任何鑰匙圈存取。"; +"about_tagline" = "願你的 token 永不用完,隨時掌握 Agent 限制。"; +"link_github" = "GitHub"; +"link_website" = "網站"; +"link_twitter" = "Twitter"; +"link_email" = "電子郵件"; +"check_updates_auto" = "自動檢查更新"; +"update_channel" = "更新頻道"; +"check_for_updates" = "檢查更新…"; +"updates_unavailable" = "此建置無法使用更新功能。"; +"copyright" = "© 2026 Peter Steinberger。MIT 許可證。"; +"section_logging" = "記錄"; +"enable_file_logging" = "啟用檔案記錄"; +"enable_file_logging_subtitle" = "將記錄寫入 %@ 以進行除錯。"; +"verbosity_title" = "詳細程度"; +"verbosity_subtitle" = "控制記錄詳細程度。"; +"open_log_file" = "開啟記錄檔"; +"force_animation_next_refresh" = "下次重新整理時強制動畫"; +"force_animation_next_refresh_subtitle" = "下次重新整理後暫時顯示載入動畫。"; +"section_loading_animations" = "載入動畫"; +"loading_animations_caption" = "選擇一個模式並在選單列中重播。「隨機」保持現有行為。"; +"animation_random_default" = "隨機(預設)"; +"replay_selected_animation" = "重播選取的動畫"; +"blink_now" = "立即閃爍"; +"section_probe_logs" = "探測記錄"; +"probe_logs_caption" = "取得最新的探測輸出以進行除錯;複製會保留完整文字。"; +"fetch_log" = "取得記錄"; +"copy" = "複製"; +"save_to_file" = "儲存到檔案"; +"load_parse_dump" = "載入解析 dump"; +"rerun_provider_autodetect" = "重新執行提供者自動偵測"; +"loading" = "載入中…"; +"no_log_yet_fetch" = "尚無記錄。取得後載入。"; +"section_fetch_strategy" = "取得策略嘗試"; +"fetch_strategy_caption" = "提供者上次取得流程中的決策和錯誤。"; +"section_openai_cookies" = "OpenAI Cookie"; +"openai_cookies_caption" = "上次 OpenAI Cookie 嘗試中的 Cookie 匯入和 WebKit 抓取記錄。"; +"no_log_yet" = "尚無記錄。請在「提供者」→「Codex」中更新 OpenAI Cookie 以執行匯入。"; +"section_caches" = "快取"; +"caches_caption" = "清除快取的費用掃描結果或瀏覽器 Cookie 快取。"; +"clear_cookie_cache" = "清除 Cookie 快取"; +"clear_cost_cache" = "清除費用快取"; +"section_notifications" = "通知"; +"notifications_caption" = "觸發 5 小時工作階段時段的測試通知(用完/恢復)。"; +"post_depleted" = "傳送用完通知"; +"post_restored" = "傳送恢復通知"; +"section_cli_sessions" = "CLI 工作階段"; +"cli_sessions_caption" = "探測後保持 Codex/Claude CLI 工作階段存活。預設在擷取資料後結束。"; +"keep_cli_sessions_alive" = "保持 CLI 工作階段存活"; +"keep_cli_sessions_alive_subtitle" = "探測之間跳過關閉流程(僅限除錯)。"; +"reset_cli_sessions" = "重置 CLI 工作階段"; +"section_error_simulation" = "錯誤模擬"; +"error_simulation_caption" = "將模擬錯誤訊息注入選單卡片以進行版面配置測試。"; +"set_menu_error" = "設定選單錯誤"; +"clear_menu_error" = "清除選單錯誤"; +"set_cost_error" = "設定費用錯誤"; +"clear_cost_error" = "清除費用錯誤"; +"section_cli_paths" = "CLI 路徑"; +"cli_paths_caption" = "解析到的 Codex 二進位檔案和 PATH 層;啟動時擷取登入 PATH(短逾時)。"; +"codex_binary" = "Codex 二進位檔案"; +"claude_binary" = "Claude 二進位檔案"; +"effective_path" = "有效 PATH"; +"unavailable" = "無法使用"; +"login_shell_path" = "登入 shell PATH(啟動時擷取)"; +"cleared" = "已清除。"; +"no_fetch_attempts" = "尚無取得嘗試。"; +"metric_pref_automatic" = "自動"; +"metric_pref_primary" = "主要"; +"metric_pref_secondary" = "次要"; +"metric_pref_tertiary" = "第三"; +"metric_pref_extra_usage" = "額外使用量"; +"metric_pref_average" = "平均"; +"display_mode_percent" = "百分比"; +"display_mode_pace" = "進度"; +"display_mode_both" = "兩者"; +"display_mode_percent_desc" = "顯示剩餘/已使用百分比(例如 45%)"; +"display_mode_pace_desc" = "顯示進度指示器(例如 +5%)"; +"display_mode_both_desc" = "同時顯示百分比和進度(例如 45% · +5%)"; +"status_operational" = "運作正常"; +"status_partial_outage" = "部分服務中斷"; +"status_major_outage" = "重大服務中斷"; +"status_critical_issue" = "嚴重問題"; +"status_maintenance" = "維護中"; +"status_unknown" = "狀態未知"; +"refresh_manual" = "手動"; +"refresh_1min" = "1 分鐘"; +"refresh_2min" = "2 分鐘"; +"refresh_5min" = "5 分鐘"; +"refresh_15min" = "15 分鐘"; +"refresh_30min" = "30 分鐘"; +"not_found" = "找不到"; +"CodexBar can't show its menu bar icon" = "CodexBar 無法顯示選單列圖示"; +"Dismiss" = "關閉"; +"Open Menu Bar Settings" = "開啟選單列設定"; +"macOS Tahoe can block menu bar apps in System Settings → Menu Bar → Allow in the Menu Bar. CodexBar is running, but macOS may be hiding its icon. Open Menu Bar settings and turn CodexBar on." = "macOS Tahoe 可能會在「系統設定」→「選單列」→「允許顯示在選單列」中封鎖選單列 App。CodexBar 正在執行,但 macOS 可能隱藏了它的圖示。請開啟選單列設定並啟用 CodexBar。"; +"cost_header_estimated" = "費用(估算)"; +"cost_estimate_hint" = "根據本機記錄估算 · 可能與帳單不同"; +"copilot_device_code" = "裝置代碼已複製到剪貼簿:%1$@\n\n請到以下網址驗證:%2$@"; +"copilot_waiting_text" = "請在瀏覽器中完成登入。\n登入完成後,此視窗會自動關閉。"; +"vertex_ai_login_instructions" = "要追蹤 Vertex AI 使用量,請透過 Google Cloud 進行認證。\n\n1. 開啟終端\n2. 執行:gcloud auth application-default login\n3. 依照瀏覽器提示登入\n4. 設定你的專案:gcloud config set project PROJECT_ID\n\n要現在開啟終端嗎?"; + +/* Popup panels */ +"No usage configured." = "尚未設定使用量。"; +"Quota" = "配額"; +"tokens" = "token"; +"requests" = "請求"; +"Latest" = "最新"; +"Monthly" = "每月"; +"Sonnet" = "Sonnet"; +"Overages" = "超額"; +"Activity" = "活動"; +"Copied" = "已複製"; +"Copy error" = "複製錯誤"; +"Copy path" = "複製路徑"; +"Extra usage spent" = "額外使用量支出"; +"Credits remaining" = "剩餘額度"; +"Using CLI fallback" = "使用 CLI 備援"; +"Balance updates in near-real time (up to 5 min lag)" = "餘額接近即時更新(最多延遲 5 分鐘)"; +"Daily billing data finalizes at 07:00 UTC" = "每日帳單資料會在 UTC 07:00 完成結算"; +"%@ of %@ credits left" = "剩餘 %@ / %@ 點額度"; +"%@ of %@ bonus credits left" = "剩餘 %@ / %@ 點獎勵額度"; +"%@ / %@ (%@ remaining)" = "%@ / %@(剩餘 %@)"; +"%@/%@ left" = "剩餘 %@ / %@"; +"Gemini Flash" = "Gemini Flash"; +"Regenerates %@" = "%@後恢復"; +"used after next regen" = "下次恢復後已使用"; +"after next regen" = "下次恢復後"; +"Near full" = "接近全滿"; +"Full in ~1 regen" = "約 1 次恢復後全滿"; +"Full in ~%.0f regens" = "約 %.0f 次恢復後全滿"; +"Overage usage" = "超額使用量"; +"Overage cost" = "超額費用"; +"credits" = "額度"; +"Zen balance" = "Zen 餘額"; +"API spend" = "API 支出"; +"Extra usage" = "額外使用量"; +"Quota usage" = "配額使用量"; +"%.0f%% used" = "已使用 %.0f%%"; +"Usage history (today)" = "使用量記錄(今天)"; +"Usage history (%d days)" = "使用量記錄(%d 天)"; +"%d percent remaining" = "剩餘 %d%%"; +"Unknown" = "未知"; +"stale data" = "資料過舊"; +"No credits history data available." = "尚無可用的額度記錄資料。"; +"Credits history chart" = "額度記錄圖表"; +"%d days of credits data" = "%d 天額度資料"; +"Usage breakdown chart" = "使用量明細圖表"; +"%d days of usage data across %d services" = "%d 天使用量資料,涵蓋 %d 個服務"; +"Cost history chart" = "費用記錄圖表"; +"%d days of cost data" = "%d 天費用資料"; +"Plan utilization chart" = "方案使用率圖表"; +"%d utilization samples" = "%d 筆使用率樣本"; +"Hourly Usage" = "每小時使用量"; +"Usage remaining" = "剩餘使用量"; +"Usage used" = "已使用使用量"; +"API key verified. Ollama does not expose Cloud quota limits through the API." = "API 金鑰已驗證。Ollama 不會透過 API 暴露 Cloud 配額限制。"; +"Last 30 days: %@ tokens" = "近 30 天:%@ token"; +"7d spend" = "7 天支出"; +"30d spend" = "30 天支出"; +"Cache read" = "快取讀取"; +"Claude Admin API 30 day spend trend" = "Claude Admin API 30 天支出趨勢"; +"OpenRouter API key spend trend" = "OpenRouter API 金鑰支出趨勢"; +"z.ai hourly token trend" = "z.ai 每小時 token 趨勢"; +"MiniMax 30 day token usage trend" = "MiniMax 30 天 token 使用量趨勢"; +"Today cash" = "今日現金"; +"DeepSeek 30 day token usage trend" = "DeepSeek 30 天 token 使用量趨勢"; +"cache-hit input" = "快取命中輸入"; +"cache-miss input" = "快取未命中輸入"; +"output" = "輸出"; +"Requests" = "請求"; +"Reported by OpenAI Admin API organization usage." = "由 OpenAI Admin API 組織使用量回報。"; +"Reported by Mistral billing usage." = "由 Mistral 帳單使用量回報。"; +"Today" = "今天"; +"Today tokens" = "今日 token"; +"30d cost" = "近 30 天費用"; +"30d tokens" = "近 30 天 token"; +"Latest tokens" = "最新 token"; +"Top model" = "主要模型"; +"Storage" = "儲存空間"; +"Add Account..." = "新增帳號…"; +"Usage Dashboard" = "使用量儀表板"; +"Status Page" = "狀態頁"; +"Settings..." = "設定…"; +"About CodexBar" = "關於 CodexBar"; +"Quit" = "結束"; +"Last %d day" = "近 %d 天"; +"Last %d days" = "近 %d 天"; +"%@ tokens" = "%@ token"; +"Latest billing day" = "最新帳單日"; +"Latest billing day (%@)" = "最新帳單日(%@)"; +"This week" = "本週"; +"Week" = "週"; +"Month" = "月"; +"Models" = "模型數"; +"24h tokens" = "24 小時 token"; +"Latest hour" = "最新小時"; +"Peak hour" = "尖峰小時"; +"Top method" = "主要方法"; +"30d cash" = "30 天現金"; +"30d billing history from MiniMax web session" = "來自 MiniMax 網頁工作階段的 30 天帳單記錄"; +"AWS Cost Explorer billing can lag." = "AWS Cost Explorer 帳單資料可能延遲。"; +"Rate limit: %d / %@" = "速率限制: %d / %@"; +"Key remaining" = "金鑰剩餘額度"; +"No limit set for the API key" = "此 API 金鑰未設定限制"; +"API key limit unavailable right now" = "目前無法取得 API 金鑰限制"; +"This month: %@ tokens" = "本月:%@ token"; +"Switch Account..." = "切換帳號…"; +"Update ready, restart now?" = "更新已就緒,要立即重新啟動嗎?"; +"Daily" = "每日"; +"Hourly Tokens" = "每小時 token"; +"No data" = "無資料"; +"No usage breakdown data available." = "尚無可用的使用量明細資料。"; +"Today: %@ · %@ tokens" = "今天:%@ · %@ token"; +"Today: %@" = "今天:%@"; +"Today: %@ tokens" = "今天:%@ token"; +"Last 30 days: %@ · %@ tokens" = "近 30 天:%@ · %@ token"; +"Last 30 days: %@" = "近 30 天:%@"; +"Est. total (30d): %@" = "估計總計(30 天):%@"; +"Est. total (%@): %@" = "估計總計(%@):%@"; +"Hover a bar for details" = "停留在長條上查看詳細資料"; +"%@: %@ · %@ tokens" = "%@:%@ · %@ token"; +"No providers selected for Overview." = "概覽尚未選擇提供者。"; +"No overview data available." = "概覽尚無可用資料。"; + +/* Additional provider settings and alerts */ +"%@ is waiting for permission" = "%@ 正在等待權限"; +"%@ requests" = "%@ 個請求"; +"%@: %@ credits" = "%@:%@ 額度"; +"30d requests" = "近 30 天請求"; +"4 days" = "4 天"; +"5 days" = "5 天"; +"7 days" = "7 天"; +"API key verifies Ollama Cloud access; cookies still expose quota limits." = "API 金鑰會驗證 Ollama Cloud 存取;Cookie 仍會提供配額限制。"; +"AWS access key ID. Can also be set with AWS_ACCESS_KEY_ID." = "AWS 存取金鑰 ID。也可以用 AWS_ACCESS_KEY_ID 設定。"; +"AWS region. Can also be set with AWS_REGION." = "AWS 區域。也可以用 AWS_REGION 設定。"; +"AWS secret access key. Can also be set with AWS_SECRET_ACCESS_KEY." = "AWS 秘密存取金鑰。也可以用 AWS_SECRET_ACCESS_KEY 設定。"; +"Access key ID" = "存取金鑰 ID"; +"Add Account" = "新增帳號"; +"Adding Account…" = "正在新增帳號…"; +"Antigravity login failed" = "Antigravity 登入失敗"; +"Antigravity login timed out" = "Antigravity 登入逾時"; +"Auth source" = "認證來源"; +"Automatic imports Chrome browser cookies from Xiaomi MiMo." = "自動匯入 Xiaomi MiMo 的 Chrome 瀏覽器 Cookie。"; +"Automatic imports Windsurf session data from Chromium browser localStorage." = "自動從 Chromium 瀏覽器 localStorage 匯入 Windsurf 工作階段資料。"; +"Automatic imports browser cookies from Bailian." = "自動匯入 Bailian 的瀏覽器 Cookie。"; +"Automatically imports browser cookies." = "自動匯入瀏覽器 Cookie。"; +"Automatically imports browser session cookies." = "自動匯入瀏覽器工作階段 Cookie。"; +"Azure OpenAI deployment name. AZURE_OPENAI_DEPLOYMENT_NAME is also supported." = "Azure OpenAI 部署名稱。也支援 AZURE_OPENAI_DEPLOYMENT_NAME。"; +"Azure OpenAI key" = "Azure OpenAI 金鑰"; +"Azure OpenAI resource endpoint. AZURE_OPENAI_ENDPOINT is also supported." = "Azure OpenAI 資源端點。也支援 AZURE_OPENAI_ENDPOINT。"; +"Base URL" = "Base URL"; +"Base URL for the LLM-API-Key-Proxy instance." = "LLM-API-Key-Proxy 實例的 Base URL。"; +"Browser cookies" = "瀏覽器 Cookie"; +"Cap end" = "上限終點"; +"Cap start" = "上限起點"; +"Capacity End" = "容量終點"; +"Capacity Start" = "容量起點"; +"Changelog" = "變更記錄"; +"Choose the Moonshot/Kimi API host for international or China mainland accounts." = "選擇國際或中國大陸帳號使用的 Moonshot/Kimi API 主機。"; +"CodexBar can't replace a system account that is signed in with an API key only setup." = "CodexBar 無法取代僅使用 API 金鑰登入設定的系統帳號。"; +"CodexBar could not find saved auth for that account. Re-authenticate it and try again." = "CodexBar 找不到該帳號已儲存的認證。請重新認證後再試。"; +"CodexBar could not read managed account storage. Recover the store before adding another account." = "CodexBar 無法讀取受管理帳號儲存區。請先修復儲存區,再新增其他帳號。"; +"CodexBar could not read saved auth for that account. Re-authenticate it and try again." = "CodexBar 無法讀取該帳號已儲存的認證。請重新認證後再試。"; +"CodexBar could not read the current system account on this Mac." = "CodexBar 無法讀取此 Mac 上目前的系統帳號。"; +"CodexBar could not replace the live Codex auth on this Mac." = "CodexBar 無法取代此 Mac 上的目前 Codex 認證。"; +"CodexBar could not safely preserve the current system account before switching." = "CodexBar 無法在切換前安全保留目前的系統帳號。"; +"CodexBar could not save the current system account before switching." = "CodexBar 無法在切換前儲存目前的系統帳號。"; +"CodexBar could not update managed account storage." = "CodexBar 無法更新受管理帳號儲存區。"; +"CodexBar found another managed account that already uses the current system account. Resolve the duplicate account before switching." = "CodexBar 發現另一個受管理帳號已使用目前的系統帳號。請先解決重複帳號,再進行切換。"; +"CodexBar will ask macOS Keychain for “%@” so it can decrypt browser cookies and authenticate your account. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求「%@」,以解密瀏覽器 Cookie 並認證你的帳號。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for the Claude Code OAuth token so it can fetch your Claude usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求 Claude Code OAuth token,以取得你的 Claude 使用量。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your Amp cookie header so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 Amp Cookie 標頭,以取得使用量。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your Augment cookie header so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 Augment Cookie 標頭,以取得使用量。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your Claude cookie header so it can fetch Claude web usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 Claude Cookie 標頭,以取得 Claude 網頁使用量。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your Cursor cookie header so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 Cursor Cookie 標頭,以取得使用量。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your Factory cookie header so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 Factory Cookie 標頭,以取得使用量。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your GitHub Copilot token so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 GitHub Copilot token,以取得使用量。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your Kimi K2 API key so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 Kimi K2 API 金鑰,以取得使用量。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your Kimi auth token so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 Kimi 認證 token,以取得使用量。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your MiniMax API token so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 MiniMax API token,以取得使用量。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your MiniMax cookie header so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 MiniMax Cookie 標頭,以取得使用量。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your OpenAI cookie header so it can fetch Codex dashboard extras. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 OpenAI Cookie 標頭,以取得 Codex 儀表板額外資料。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your OpenCode cookie header so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 OpenCode Cookie 標頭,以取得使用量。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your Synthetic API key so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 Synthetic API 金鑰,以取得使用量。按一下「確定」繼續。"; +"CodexBar will ask macOS Keychain for your z.ai API token so it can fetch usage. Click OK to continue." = "CodexBar 將向 macOS 鑰匙圈要求你的 z.ai API token,以取得使用量。按一下「確定」繼續。"; +"Could not open Cursor login in your browser." = "無法在瀏覽器中開啟 Cursor 登入。"; +"Could not open browser for Antigravity" = "無法為 Antigravity 開啟瀏覽器"; +"Credits used" = "已用額度"; +"Day" = "日期"; +"Deployment" = "部署"; +"Drag to reorder" = "拖曳以重新排序"; +"Endpoint" = "端點"; +"Enterprise host" = "Enterprise 主機"; +"Extra usage balance: %@" = "額外使用量餘額:%@"; +"Keychain Access Required" = "需要鑰匙圈存取權"; +"Kiro menu bar value" = "Kiro 選單列數值"; +"Label" = "標籤"; +"No organizations loaded. Click Refresh after setting your API key." = "尚未載入組織。設定 API 金鑰後按一下「重新整理」。"; +"No output captured." = "未擷取到輸出。"; +"No system account" = "沒有系統帳號"; +"Oasis-Token" = "Oasis-Token"; +"Open Augment (Log Out & Back In)" = "開啟 Augment(登出後重新登入)"; +"Open Codebuff Dashboard" = "開啟 Codebuff 儀表板"; +"Open Command Code Settings" = "開啟 Command Code 設定"; +"Open Crof dashboard" = "開啟 Crof 儀表板"; +"Open Manus" = "開啟 Manus"; +"Open MiMo Balance" = "開啟 MiMo 餘額"; +"Open Moonshot Console" = "開啟 Moonshot 主控台"; +"Open Ollama API Keys" = "開啟 Ollama API 金鑰"; +"Open StepFun Platform" = "開啟 StepFun 平台"; +"Open T3 Chat Settings" = "開啟 T3 Chat 設定"; +"Open Volcengine Ark Console" = "開啟 Volcengine Ark 主控台"; +"Open legacy provider docs" = "開啟舊版提供者文件"; +"Open projects" = "開啟專案"; +"Open this URL manually to continue login:\n\n%@" = "手動開啟此 URL 以繼續登入:\n\n%@"; +"Optional organization ID for accounts linked to multiple Anthropic organizations." = "適用於連結多個 Anthropic 組織的帳號,可選填組織 ID。"; +"Optional. Applies to the configured Admin API key; selected token accounts do not inherit OPENAI_PROJECT_ID." = "選填。套用到已設定的 Admin API 金鑰;選取的 token 帳號不會繼承 OPENAI_PROJECT_ID。"; +"Optional. Enter your GitHub Enterprise host, for example octocorp.ghe.com. Leave blank for github.com." = "選填。輸入你的 GitHub Enterprise 主機,例如 octocorp.ghe.com。留空則使用 github.com。"; +"Optional. Leave blank to discover and aggregate projects visible to the API key." = "選填。留空會探索並彙總 API 金鑰可見的專案。"; +"Org ID (optional)" = "組織 ID(選填)"; +"Organizations" = "組織"; +"Password" = "密碼"; +"%@ authentication is disabled." = "%@ 認證已停用。"; +"%@ cookies are disabled." = "%@ Cookie 已停用。"; +"%@ web API access is disabled." = "%@ Web API 存取已停用。"; +"Disable %@ dashboard cookie usage." = "停用 %@ 儀表板 Cookie 用法。"; +"Keychain access is disabled in Advanced, so browser cookie import is unavailable." = "進階設定中已停用鑰匙圈存取,因此無法匯入瀏覽器 Cookie。"; +"Manually paste an %@ from a browser session." = "從瀏覽器工作階段中手動貼上 %@。"; +"Paste a Cookie header captured from %@." = "貼上從 %@ 擷取的 Cookie 標頭。"; +"Paste a Cookie header from %@." = "貼上來自 %@ 的 Cookie 標頭。"; +"Paste a Cookie header or cURL capture from %@." = "貼上來自 %@ 的 Cookie 標頭或 cURL 擷取內容。"; +"Paste a Cookie header or full cURL capture from %@." = "貼上來自 %@ 的 Cookie 標頭或完整 cURL 擷取內容。"; +"Paste a Cookie or Authorization header from %@." = "貼上來自 %@ 的 Cookie 或 Authorization 標頭。"; +"Paste a full cookie header or the %@ value." = "貼上完整 Cookie 標頭或 %@ 值。"; +"Paste a Cookie header or full cURL capture from T3 Chat settings." = "貼上 T3 Chat 設定中的 Cookie 標頭或完整 cURL 擷取內容。"; +"Paste the Cookie header from a request to admin.mistral.ai. Must contain an ory_session_* cookie." = "貼上發往 admin.mistral.ai 請求中的 Cookie 標頭。必須包含 ory_session_* Cookie。"; +"Paste the Oasis-Token from a logged-in browser session on platform.stepfun.com." = "貼上 platform.stepfun.com 已登入瀏覽器工作階段中的 Oasis-Token。"; +"Paste the %@ JSON bundle from %@." = "貼上來自 %2$@ 的 %1$@ JSON 組合。"; +"Paste the %@ value or a full Cookie header." = "貼上 %@ 值或完整 Cookie 標頭。"; +"Personal account" = "個人帳號"; +"Project ID" = "專案 ID"; +"Re-auth" = "重新認證"; +"Re-authenticating…" = "正在重新認證…"; +"Refresh Session" = "重新整理工作階段"; +"Refresh organizations" = "重新整理組織"; +"Region" = "區域"; +"Reload" = "重新載入"; +"Reorder" = "重新排序"; +"Secret access key" = "秘密存取金鑰"; +"Series" = "序列"; +"Service" = "服務"; +"Show or hide Kiro credits, percent, or both next to the menu bar icon." = "在選單列圖示旁顯示或隱藏 Kiro 額度、百分比,或兩者都顯示。"; +"Show usage for organizations you belong to. Personal account is always shown." = "顯示你所屬組織的使用量。個人帳號一律顯示。"; +"Sign in to cursor.com in your browser, then refresh Cursor in CodexBar." = "請在瀏覽器中登入 cursor.com,然後在 CodexBar 重新整理 Cursor。"; +"Simulated error text" = "模擬錯誤文字"; +"StepFun platform account (phone number or email)." = "StepFun 平台帳號(電話號碼或電子郵件)。"; +"Stored in ~/.codexbar/config.json." = "儲存在 ~/.codexbar/config.json 中。"; +"Stored in ~/.codexbar/config.json. AZURE_OPENAI_API_KEY is also supported." = "儲存在 ~/.codexbar/config.json 中。也支援 AZURE_OPENAI_API_KEY。"; +"Stored in ~/.codexbar/config.json. For the official Kimi API, use Moonshot / Kimi API." = "儲存在 ~/.codexbar/config.json 中。官方 Kimi API 請使用 Moonshot / Kimi API。"; +"Stored in ~/.codexbar/config.json. Get your API key from the Volcengine Ark console." = "儲存在 ~/.codexbar/config.json 中。請從 Volcengine Ark 主控台取得 API 金鑰。"; +"Stored in ~/.codexbar/config.json. Get your key from Ollama settings." = "儲存在 ~/.codexbar/config.json 中。請從 Ollama 設定取得金鑰。"; +"Stored in ~/.codexbar/config.json. Get your key from console.deepgram.com." = "儲存在 ~/.codexbar/config.json 中。請從 console.deepgram.com 取得金鑰。"; +"Stored in ~/.codexbar/config.json. Get your key from elevenlabs.io/app/settings/api-keys." = "儲存在 ~/.codexbar/config.json 中。請從 elevenlabs.io/app/settings/api-keys 取得金鑰。"; +"Stored in ~/.codexbar/config.json. Get your key from openrouter.ai/settings/keys and set a key spending limit there to enable API key quota tracking." = "儲存在 ~/.codexbar/config.json 中。請從 openrouter.ai/settings/keys 取得金鑰,並在該處設定金鑰支出上限以啟用 API 金鑰配額追蹤。"; +"Stored in ~/.codexbar/config.json. In Warp, open Settings > Platform > API Keys, then create one." = "儲存在 ~/.codexbar/config.json 中。在 Warp 中開啟 Settings > Platform > API Keys,然後建立金鑰。"; +"Stored in ~/.codexbar/config.json. Metrics require Groq Enterprise Prometheus access." = "儲存在 ~/.codexbar/config.json 中。指標需要 Groq Enterprise Prometheus 存取權。"; +"Stored in ~/.codexbar/config.json. OPENAI_ADMIN_KEY is preferred; OPENAI_API_KEY still works." = "儲存在 ~/.codexbar/config.json 中。優先使用 OPENAI_ADMIN_KEY;OPENAI_API_KEY 仍可使用。"; +"Stored in ~/.codexbar/config.json. Requires an Anthropic Admin API key." = "儲存在 ~/.codexbar/config.json 中。需要 Anthropic Admin API 金鑰。"; +"Stored in ~/.codexbar/config.json. Used for /v1/quota-stats." = "儲存在 ~/.codexbar/config.json 中。用於 /v1/quota-stats。"; +"Stored in ~/.codexbar/config.json. You can also provide CODEBUFF_API_KEY or let CodexBar read ~/.config/manicode/credentials.json (created by `codebuff login`)." = "儲存在 ~/.codexbar/config.json 中。你也可以提供 CODEBUFF_API_KEY,或讓 CodexBar 讀取 `codebuff login` 建立的 ~/.config/manicode/credentials.json。"; +"Stored in ~/.codexbar/config.json. You can also provide CROF_API_KEY." = "儲存在 ~/.codexbar/config.json 中。你也可以提供 CROF_API_KEY。"; +"Stored in ~/.codexbar/config.json. You can also provide KILO_API_KEY or ~/.local/share/kilo/auth.json (kilo.access)." = "儲存在 ~/.codexbar/config.json 中。你也可以提供 KILO_API_KEY 或 ~/.local/share/kilo/auth.json(kilo.access)。"; +"T3 Chat cookie" = "T3 Chat Cookie"; +"That account is no longer available in CodexBar. Refresh the account list and try again." = "該帳號已無法在 CodexBar 中使用。請重新整理帳號列表後再試。"; +"The browser login did not complete in time. Try Antigravity login again." = "瀏覽器登入未在時限內完成。請再次嘗試 Antigravity 登入。"; +"Timed out waiting for Cursor login. %@" = "等待 Cursor 登入逾時。%@"; +"Timed out waiting for Cursor login. %@ Last error: %@" = "等待 Cursor 登入逾時。%@ 最後錯誤:%@"; +"Today requests" = "今日請求"; +"Total (30d): %@ credits" = "總計(30 天):%@ 額度"; +"Username" = "使用者名稱"; +"Uses username + password to login and obtain an Oasis-Token automatically." = "使用使用者名稱與密碼登入,並自動取得 Oasis-Token。"; +"Uses username + password to login and obtain an %@ automatically." = "使用使用者名稱與密碼登入,並自動取得 %@。"; +"Utilization End" = "使用率終點"; +"Utilization Start" = "使用率起點"; +"Verbosity" = "詳細程度"; +"Windsurf session JSON bundle" = "Windsurf 工作階段 JSON 組合"; +"Workspace ID" = "工作區 ID"; +"Your StepFun platform password. Used to login and obtain a session token." = "你的 StepFun 平台密碼。用於登入並取得工作階段 token。"; +"claude /login exited with status %d." = "claude /login 以狀態 %d 結束。"; +"codex login exited with status %d." = "codex login 以狀態 %d 結束。"; +"Cookie: …\n\nor paste a cURL capture from the Abacus AI dashboard" = "Cookie: …\n\n或貼上 Abacus AI 儀表板的 cURL 擷取內容"; +"Cookie: …\n\nor paste the __Secure-next-auth.session-token value" = "Cookie: …\n\n或貼上 __Secure-next-auth.session-token 值"; +"Cookie: …\n\nor paste the kimi-auth token value" = "Cookie: …\n\n或貼上 kimi-auth token 值"; +"session_id=...\n\nor paste just the session_id value" = "session_id=...\n\n或只貼上 session_id 值"; +"Clear" = "清除"; +"No matching providers" = "沒有相符的提供者"; +"Search providers" = "搜尋提供者"; diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 5d295b7f..3ea0ada2 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -47,6 +47,24 @@ enum SessionQuotaNotificationLogic { if wasDepleted, !isDepleted { return .restored } return .none } + + static func notificationCopy( + transition: SessionQuotaTransition, + providerName: String) -> (title: String, body: String) + { + switch transition { + case .none: + ("", "") + case .depleted: + ( + L("session_depleted_notification_title", providerName), + L("session_depleted_notification_body")) + case .restored: + ( + L("session_restored_notification_title", providerName), + L("session_restored_notification_body")) + } + } } enum QuotaWarningNotificationLogic { @@ -57,13 +75,24 @@ enum QuotaWarningNotificationLogic { currentRemaining: Double, accountDisplayName: String? = nil) -> (title: String, body: String) { - let windowLabel = window.displayName + let windowLabel = window.localizedNotificationDisplayName let remainingText = Self.percentText(currentRemaining) - let accountPrefix = accountDisplayName - .map { "Account \($0). " } ?? "" - return ( - "\(providerName) \(windowLabel) quota low", - "\(accountPrefix)\(remainingText) left. Reached your \(threshold)% \(windowLabel) warning threshold.") + let title = L("quota_warning_notification_title", providerName, windowLabel) + let body = if let accountDisplayName { + L( + "quota_warning_notification_body_with_account", + accountDisplayName, + remainingText, + threshold, + windowLabel) + } else { + L( + "quota_warning_notification_body", + remainingText, + threshold, + windowLabel) + } + return (title, body) } static func crossedThreshold( @@ -116,14 +145,9 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName - let (title, body) = switch transition { - case .none: - ("", "") - case .depleted: - ("\(providerName) session depleted", "0% left. Will notify when it's available again.") - case .restored: - ("\(providerName) session restored", "Session quota is available again.") - } + let (title, body) = SessionQuotaNotificationLogic.notificationCopy( + transition: transition, + providerName: providerName) let providerText = provider.rawValue let transitionText = String(describing: transition) @@ -156,3 +180,12 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { AppNotifications.shared.post(idPrefix: idPrefix, title: copy.title, body: copy.body, soundEnabled: false) } } + +extension QuotaWarningWindow { + fileprivate var localizedNotificationDisplayName: String { + switch self { + case .session: L("quota_warning_session") + case .weekly: L("quota_warning_weekly") + } + } +} diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 5f2035fe..c9f38e5c 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -181,6 +181,18 @@ extension SettingsStore { } } + var weeklyProgressWorkDays: Int? { + get { self.defaultsState.weeklyProgressWorkDays } + set { + self.defaultsState.weeklyProgressWorkDays = newValue + if let newValue { + self.userDefaults.set(newValue, forKey: "weeklyProgressWorkDays") + } else { + self.userDefaults.removeObject(forKey: "weeklyProgressWorkDays") + } + } + } + var usageBarsShowUsed: Bool { get { self.defaultsState.usageBarsShowUsed } set { diff --git a/Sources/CodexBar/SettingsStore+MenuObservation.swift b/Sources/CodexBar/SettingsStore+MenuObservation.swift index 2af6b263..3776c1bb 100644 --- a/Sources/CodexBar/SettingsStore+MenuObservation.swift +++ b/Sources/CodexBar/SettingsStore+MenuObservation.swift @@ -19,6 +19,7 @@ extension SettingsStore { _ = self.quotaWarningWindowEnabled(.weekly) _ = self.quotaWarningSoundEnabled _ = self.quotaWarningMarkersVisible + _ = self.weeklyProgressWorkDays _ = self.usageBarsShowUsed _ = self.resetTimesShowAbsolute _ = self.providerChangelogLinksEnabled @@ -31,6 +32,7 @@ extension SettingsStore { _ = self.menuBarMetricPreferencesRaw _ = self.costUsageEnabled _ = self.costUsageHistoryDays + _ = self.appLanguage _ = self.hidePersonalInfo _ = self.randomBlinkEnabled _ = self.confettiOnWeeklyLimitResetsEnabled diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 92552809..e04b084a 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -108,11 +108,18 @@ enum MultiAccountMenuLayout: String, CaseIterable, Identifiable { } } +struct CachedCodexAccountReconciliationSnapshot { + let activeSource: CodexActiveSource + let loadedAt: Date + let snapshot: CodexAccountReconciliationSnapshot +} + @MainActor @Observable final class SettingsStore { static let sharedDefaults = AppGroupSupport.sharedDefaults() static let mergedOverviewProviderLimit = 3 + static let productionCodexAccountReconciliationSnapshotCacheInterval: TimeInterval = 2 static let isRunningTests: Bool = { let env = ProcessInfo.processInfo.environment if env["XCTestConfigurationFilePath"] != nil { return true } @@ -121,12 +128,18 @@ final class SettingsStore { return NSClassFromString("XCTestCase") != nil }() + #if DEBUG + static var codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting: TimeInterval? + #endif + @ObservationIgnored let userDefaults: UserDefaults @ObservationIgnored let configStore: CodexBarConfigStore @ObservationIgnored var config: CodexBarConfig @ObservationIgnored var configPersistTask: Task? @ObservationIgnored var configLoading = false @ObservationIgnored var tokenAccountsLoaded = false + @ObservationIgnored var cachedCodexAccountReconciliationSnapshot: + CachedCodexAccountReconciliationSnapshot? var defaultsState: SettingsDefaultsState var configRevision: Int = 0 var providerOrder: [UsageProvider] = [] @@ -324,6 +337,7 @@ extension SettingsStore { if Self.isRunningTests, quotaWarningMarkersVisibleDefault == nil { userDefaults.set(true, forKey: "quotaWarningMarkersVisible") } + let weeklyProgressWorkDays = userDefaults.object(forKey: "weeklyProgressWorkDays") as? Int let usageBarsShowUsed = userDefaults.object(forKey: "usageBarsShowUsed") as? Bool ?? false let resetTimesShowAbsolute = userDefaults.object(forKey: "resetTimesShowAbsolute") as? Bool ?? false let providerChangelogLinksEnabled = userDefaults.object( @@ -404,6 +418,7 @@ extension SettingsStore { quotaWarningWeeklyEnabled: quotaWarnings.weeklyEnabled, quotaWarningSoundEnabled: quotaWarnings.soundEnabled, quotaWarningMarkersVisible: quotaWarningMarkersVisible, + weeklyProgressWorkDays: weeklyProgressWorkDays, usageBarsShowUsed: usageBarsShowUsed, resetTimesShowAbsolute: resetTimesShowAbsolute, providerChangelogLinksEnabled: providerChangelogLinksEnabled, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 99b495c1..c1cca3f4 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -19,6 +19,7 @@ struct SettingsDefaultsState { var quotaWarningWeeklyEnabled: Bool var quotaWarningSoundEnabled: Bool var quotaWarningMarkersVisible: Bool + var weeklyProgressWorkDays: Int? var usageBarsShowUsed: Bool var resetTimesShowAbsolute: Bool var providerChangelogLinksEnabled: Bool diff --git a/Sources/CodexBar/StatusItemController+Actions.swift b/Sources/CodexBar/StatusItemController+Actions.swift index db2010e8..6a3c7b41 100644 --- a/Sources/CodexBar/StatusItemController+Actions.swift +++ b/Sources/CodexBar/StatusItemController+Actions.swift @@ -1,19 +1,42 @@ import AppKit import CodexBarCore +enum LoginNotificationLogic { + static func notificationCopy(providerName: String) -> (title: String, body: String) { + ( + L("login_success_notification_title", providerName), + L("login_success_notification_body")) + } +} + extension StatusItemController: StatusItemMenuPersistentActionDelegate { // MARK: - Actions reachable from menus - func refreshStore(forceTokenUsage: Bool, refreshOpenMenusWhenComplete: Bool = true) { + func refreshStore( + forceTokenUsage: Bool, + refreshOpenMenusWhenComplete: Bool = true, + interaction: ProviderInteraction = .userInitiated) + { Task { - await ProviderInteractionContext.$current.withValue(.userInitiated) { - await self.store.refresh(forceTokenUsage: forceTokenUsage) - self.store.scheduleStorageFootprintRefreshForOverview(force: true) - if refreshOpenMenusWhenComplete { - self.refreshOpenMenusAfterExplicitStoreAction() - } else { - self.invalidateMenus() - } + await self.performStoreRefresh( + forceTokenUsage: forceTokenUsage, + refreshOpenMenusWhenComplete: refreshOpenMenusWhenComplete, + interaction: interaction) + } + } + + func performStoreRefresh( + forceTokenUsage: Bool, + refreshOpenMenusWhenComplete: Bool, + interaction: ProviderInteraction) async + { + await ProviderInteractionContext.$current.withValue(interaction) { + await self.store.refresh(forceTokenUsage: forceTokenUsage) + self.store.scheduleStorageFootprintRefreshForOverview(force: true) + if refreshOpenMenusWhenComplete { + self.refreshOpenMenusAfterExplicitStoreAction() + } else { + self.invalidateMenus() } } } @@ -177,9 +200,10 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } guard self.settings.hasUnreadableManagedCodexAccountStore == false else { self.presentLoginAlert( - title: "Managed Codex accounts unavailable", - message: "CodexBar could not read managed account storage. " + - "Recover the store before adding another account.") + title: L("Managed Codex accounts unavailable"), + message: L( + "CodexBar could not read managed account storage. " + + "Recover the store before adding another account.")) return } @@ -373,28 +397,16 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { } private func presentManagedCodexAccountError(_ error: Error) { - let info: LoginAlertInfo - if let error = error as? ManagedCodexAccountCoordinatorError, - error == .authenticationInProgress + let info = if let error = error as? ManagedCodexAccountCoordinatorError, + error == .authenticationInProgress { - info = LoginAlertInfo( - title: "Codex account login already running", - message: "Wait for the current managed Codex login to finish before adding another account.") + LoginAlertInfo( + title: L("Codex account login already running"), + message: L("Wait for the current managed Codex login to finish before adding another account.")) } else if let error = error as? ManagedCodexAccountServiceError { - let message = switch error { - case .loginFailed: - L("managed_login_failed") - case .missingEmail: - "Codex login completed, but no account email was available. " + - "Try again after confirming the account is fully signed in." - case .workspaceSelectionCancelled: - "CodexBar found multiple workspaces, but no workspace was selected." - case let .unsafeManagedHome(path): - "CodexBar refused to modify an unexpected managed home path: \(path)" - } - info = LoginAlertInfo(title: "Could not add Codex account", message: message) + LoginAlertInfo(title: L("Could not add Codex account"), message: error.userFacingMessage) } else { - info = LoginAlertInfo(title: "Could not add Codex account", message: error.localizedDescription) + LoginAlertInfo(title: L("Could not add Codex account"), message: error.localizedDescription) } self.presentLoginAlert(title: info.title, message: info.message) @@ -406,18 +418,18 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { return case .missingBinary: self.presentLoginAlert( - title: "Claude CLI not found", - message: "Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again.") + title: L("Claude CLI not found"), + message: L("Install the Claude CLI (npm i -g @anthropic-ai/claude-code) and try again.")) case let .launchFailed(message): - self.presentLoginAlert(title: "Could not start claude /login", message: message) + self.presentLoginAlert(title: L("Could not start claude /login"), message: message) case .timedOut: self.presentLoginAlert( - title: "Claude login timed out", + title: L("Claude login timed out"), message: self.trimmedLoginOutput(result.output)) case let .failed(status): - let statusLine = "claude /login exited with status \(status)." + let statusLine = String(format: L("claude /login exited with status %d."), status) let message = self.trimmedLoginOutput(result.output.isEmpty ? statusLine : result.output) - self.presentLoginAlert(title: "Claude login failed", message: message) + self.presentLoginAlert(title: L("Claude login failed"), message: message) } } @@ -485,10 +497,10 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { nil case .missingBinary: LoginAlertInfo( - title: "Gemini CLI not found", - message: "Install the Gemini CLI (npm i -g @google/gemini-cli) and try again.") + title: L("Gemini CLI not found"), + message: L("Install the Gemini CLI (npm i -g @google/gemini-cli) and try again.")) case let .launchFailed(message): - LoginAlertInfo(title: "Could not open Terminal for Gemini", message: message) + LoginAlertInfo(title: L("Could not open Terminal for Gemini"), message: message) } } @@ -498,21 +510,21 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { nil case .timedOut: LoginAlertInfo( - title: "Antigravity login timed out", - message: "The browser login did not complete in time. Try Antigravity login again.") + title: L("Antigravity login timed out"), + message: L("The browser login did not complete in time. Try Antigravity login again.")) case let .launchFailed(message): LoginAlertInfo( - title: "Could not open browser for Antigravity", - message: "Open this URL manually to continue login:\n\n\(message)") + title: L("Could not open browser for Antigravity"), + message: String(format: L("Open this URL manually to continue login:\n\n%@"), message)) case let .failed(message): - LoginAlertInfo(title: "Antigravity login failed", message: message) + LoginAlertInfo(title: L("Antigravity login failed"), message: message) } } func presentLoginAlert(title: String, message: String) { let alert = NSAlert() - alert.messageText = title - alert.informativeText = message + alert.messageText = L(title) + alert.informativeText = L(message) alert.alertStyle = .warning alert.runModal() } @@ -520,7 +532,7 @@ extension StatusItemController: StatusItemMenuPersistentActionDelegate { private func trimmedLoginOutput(_ text: String) -> String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let limit = 600 - if trimmed.isEmpty { return "No output captured." } + if trimmed.isEmpty { return L("No output captured.") } if trimmed.count <= limit { return trimmed } let idx = trimmed.index(trimmed.startIndex, offsetBy: limit) return "\(trimmed[.. Bool { if self.shouldMergeIcons { let primaryProvider = self.primaryProviderForUnifiedIcon() @@ -338,6 +337,9 @@ extension StatusItemController { "anim=\(needsAnimation ? "1" : "0")", ].joined(separator: "|") if self.shouldSkipMergedIconRender(signature) { + // AppKit can lose button title/image-position state independently of the cached render signature. + // Keep the cheap title path self-healing even when the icon image itself can be skipped. + self.setButtonTitle(displayText, for: button) self.noteIconPerfRender(skipped: true) return true } @@ -667,6 +669,11 @@ extension StatusItemController { mode: self.settings.kiroMenuBarDisplayMode, showUsed: self.settings.usageBarsShowUsed) } + if self.settings.menuBarMetricPreference(for: provider, snapshot: snapshot) == .extraUsage, + let spend = Self.extraUsageSpendDisplayText(snapshot: snapshot) + { + return spend + } let percentWindow = self.menuBarPercentWindow(for: provider, snapshot: snapshot) let mode = self.settings.menuBarDisplayMode @@ -752,6 +759,16 @@ extension StatusItemController { removingSuffix: " left") } + nonisolated static func extraUsageSpendDisplayText(snapshot: UsageSnapshot?) -> String? { + guard let cost = snapshot?.providerCost, + cost.limit > 0, + cost.used >= 0 + else { + return nil + } + return UsageFormatter.currencyString(cost.used, currencyCode: cost.currencyCode) + } + nonisolated static func kiroDisplayText( snapshot: UsageSnapshot?, mode: KiroMenuBarDisplayMode, @@ -900,7 +917,7 @@ extension StatusItemController { { return selected } - for provider in UsageProvider.allCases { + for provider in self.store.enabledProviders() { if self.store.isEnabled(provider), self.store.snapshot(for: provider) != nil { return provider } diff --git a/Sources/CodexBar/StatusItemController+CostMenuCard.swift b/Sources/CodexBar/StatusItemController+CostMenuCard.swift index 90bcaed7..a0bf4793 100644 --- a/Sources/CodexBar/StatusItemController+CostMenuCard.swift +++ b/Sources/CodexBar/StatusItemController+CostMenuCard.swift @@ -1,7 +1,9 @@ import AppKit extension StatusItemController { - static let costMenuTitle = "Cost" + static var costMenuTitle: String { + L("Cost") + } func makeCostMenuCardItem(model: UsageMenuCardView.Model, submenu: NSMenu?) -> NSMenuItem { let tooltipLines = Self.costMenuTooltipLines(tokenUsage: model.tokenUsage) diff --git a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift index 511d1b91..1f080abe 100644 --- a/Sources/CodexBar/StatusItemController+HostedSubmenus.swift +++ b/Sources/CodexBar/StatusItemController+HostedSubmenus.swift @@ -8,7 +8,6 @@ extension StatusItemController { Self.usageBreakdownChartID, Self.creditsHistoryChartID, Self.costHistoryChartID, - Self.openAIAPIUsageChartID, Self.usageHistoryChartID, Self.storageBreakdownID, Self.zaiHourlyUsageChartID, @@ -63,14 +62,6 @@ extension StatusItemController { } else { false } - case Self.openAIAPIUsageChartID: - if let providerRawValue = placeholder.toolTip, - let provider = UsageProvider(rawValue: providerRawValue) - { - self.appendOpenAIAPIUsageChartItem(to: menu, provider: provider, width: width) - } else { - false - } case Self.usageHistoryChartID: if let providerRawValue = placeholder.toolTip, let provider = UsageProvider(rawValue: providerRawValue) @@ -100,11 +91,82 @@ extension StatusItemController { } guard !didHydrate else { return } + self.appendHostedSubviewUnavailableItem(to: menu, chartID: chartID, providerRawValue: placeholder.toolTip) + } + + func refreshHostedSubviewMenu(_ menu: NSMenu) { + let width = self.renderedMenuWidth(for: menu) + guard let identity = self.hostedSubviewIdentity(for: menu) else { + self.refreshHostedSubviewHeights(in: menu) + return + } + + menu.removeAllItems() + let didHydrate: Bool = switch identity.chartID { + case Self.usageBreakdownChartID: + self.appendUsageBreakdownChartItem(to: menu, width: width) + case Self.creditsHistoryChartID: + self.appendCreditsHistoryChartItem(to: menu, width: width) + case Self.costHistoryChartID: + if let provider = identity.provider { + self.appendCostHistoryChartItem(to: menu, provider: provider, width: width) + } else { + false + } + case Self.usageHistoryChartID: + if let provider = identity.provider { + self.appendUsageHistoryChartItem(to: menu, provider: provider, width: width) + } else { + false + } + case Self.storageBreakdownID: + if let provider = identity.provider { + self.appendStorageBreakdownItem(to: menu, provider: provider, width: width) + } else { + false + } + case Self.zaiHourlyUsageChartID: + if let provider = identity.provider { + self.appendZaiHourlyUsageChartItem(to: menu, provider: provider, width: width) + } else { + false + } + default: + false + } + + if didHydrate { + self.refreshHostedSubviewHeights(in: menu) + } else { + self.appendHostedSubviewUnavailableItem( + to: menu, + chartID: identity.chartID, + providerRawValue: identity.provider?.rawValue ?? identity.providerRawValue) + } + } + + private func hostedSubviewIdentity(for menu: NSMenu) + -> (chartID: String, provider: UsageProvider?, providerRawValue: String?)? { + for item in menu.items { + guard let chartID = item.representedObject as? String else { continue } + let providerRawValue = item.toolTip + return ( + chartID: chartID, + provider: providerRawValue.flatMap(UsageProvider.init(rawValue:)), + providerRawValue: providerRawValue) + } + return nil + } - let unavailableItem = NSMenuItem(title: "No data available", action: nil, keyEquivalent: "") + private func appendHostedSubviewUnavailableItem( + to menu: NSMenu, + chartID: String, + providerRawValue: String?) + { + let unavailableItem = NSMenuItem(title: L("No data available"), action: nil, keyEquivalent: "") unavailableItem.isEnabled = false unavailableItem.representedObject = chartID - unavailableItem.toolTip = placeholder.toolTip + unavailableItem.toolTip = providerRawValue menu.addItem(unavailableItem) } @@ -169,13 +231,14 @@ extension StatusItemController { provider: UsageProvider, width: CGFloat) -> Bool { - guard let tokenSnapshot = self.store.tokenSnapshot(for: provider) else { return false } + guard let tokenSnapshot = self.tokenSnapshotForCostHistorySubmenu(provider: provider) else { return false } guard !tokenSnapshot.daily.isEmpty else { return false } if !Self.menuCardRenderingEnabled { let chartItem = NSMenuItem() chartItem.isEnabled = true chartItem.representedObject = Self.costHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } @@ -184,7 +247,9 @@ extension StatusItemController { provider: provider, daily: tokenSnapshot.daily, totalCostUSD: tokenSnapshot.last30DaysCostUSD, + currencyCode: tokenSnapshot.currencyCode, historyDays: tokenSnapshot.historyDays, + windowLabel: tokenSnapshot.historyLabel, width: width) let hosting = MenuHostingView(rootView: chartView) let controller = NSHostingController(rootView: chartView) @@ -195,40 +260,7 @@ extension StatusItemController { chartItem.view = hosting chartItem.isEnabled = true chartItem.representedObject = Self.costHistoryChartID - submenu.addItem(chartItem) - return true - } - - @discardableResult - func appendOpenAIAPIUsageChartItem( - to submenu: NSMenu, - provider: UsageProvider, - width: CGFloat) - -> Bool - { - guard provider == .openai, - let snapshot = self.store.snapshot(for: provider)?.openAIAPIUsage, - !snapshot.daily.isEmpty - else { return false } - - if !Self.menuCardRenderingEnabled { - let chartItem = NSMenuItem() - chartItem.isEnabled = true - chartItem.representedObject = Self.openAIAPIUsageChartID - submenu.addItem(chartItem) - return true - } - - let chartView = OpenAIAPIUsageChartMenuView(snapshot: snapshot, width: width) - let hosting = MenuHostingView(rootView: chartView) - let controller = NSHostingController(rootView: chartView) - let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) - hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) - - let chartItem = NSMenuItem() - chartItem.view = hosting - chartItem.isEnabled = true - chartItem.representedObject = Self.openAIAPIUsageChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 0b57fe21..90182040 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -33,7 +33,6 @@ extension StatusItemController { static let usageBreakdownChartID = "usageBreakdownChart" static let creditsHistoryChartID = "creditsHistoryChart" static let costHistoryChartID = "costHistoryChart" - static let openAIAPIUsageChartID = "openAIAPIUsageChart" static let usageHistoryChartID = "usageHistoryChart" static let storageBreakdownID = "storageBreakdown" @@ -74,13 +73,24 @@ extension StatusItemController { } func menuWillOpen(_ menu: NSMenu) { + let menuOpenStartedAt = CACurrentMediaTime() + defer { + self.logMenuOperationDurationIfSlow( + "menuWillOpen", + startedAt: menuOpenStartedAt, + menu: menu, + provider: self.menuProvider(for: menu)) + } + + self.cancelDeferredMenuInteractionRefreshTask() + if self.isHostedSubviewMenu(menu) { self.hydrateHostedSubviewMenuIfNeeded(menu) self.refreshHostedSubviewHeights(in: menu) - if Self.menuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { - self.store.requestOpenAIDashboardRefreshIfStale(reason: "submenu open") + if self.isMenuRefreshEnabled, self.isOpenAIWebSubviewMenu(menu) { + self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "submenu open") } - if Self.menuRefreshEnabled { + if self.isMenuRefreshEnabled { // Intentionally skip open-menu tracking when refresh is disabled (tests). // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. self.openMenus[ObjectIdentifier(menu)] = menu @@ -108,19 +118,21 @@ extension StatusItemController { } } - let didRefresh = self.menuNeedsRefresh(menu) - if didRefresh { + if self.isMenuRefreshEnabled, (provider ?? self.lastMenuProvider) == .codex { + self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "parent menu open") + } + + if self.menuNeedsRefresh(menu) { self.populateMenu(menu, provider: provider) self.markMenuFresh(menu) // Heights are already set during populateMenu, no need to remeasure } - if Self.menuRefreshEnabled { + if self.isMenuRefreshEnabled { // Intentionally skip open-menu tracking when refresh is disabled (tests). // If refresh is re-enabled while this menu stays open, it will not be backfilled until next open. self.openMenus[ObjectIdentifier(menu)] = menu - } - // Only schedule refresh after menu is registered as open - refreshNow is called async - if Self.menuRefreshEnabled { + self.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) + // Only schedule refresh after menu is registered as open - refreshNow is called async self.scheduleOpenMenuRefresh(for: menu) } } @@ -129,15 +141,25 @@ extension StatusItemController { let wasHostedSubviewMenu = self.isHostedSubviewMenu(menu) self.forgetClosedMenu(menu) if wasHostedSubviewMenu { - self.refreshOpenMenusIfNeeded() + self.refreshOpenMenusAfterHostedSubviewClose() } } func forgetClosedMenu(_ menu: NSMenu) { let key = ObjectIdentifier(menu) + if key == self.providerSwitcherShortcutMenuID { + self.removeProviderSwitcherShortcutMonitor() + } + self.openMenus.removeValue(forKey: key) self.menuRefreshTasks.removeValue(forKey: key)?.cancel() + self.openMenuRebuildTasks.removeValue(forKey: key)?.cancel() + self.openMenuRebuildTokens.removeValue(forKey: key) + self.openMenuRebuildsClosingHostedSubviewMenus.remove(key) + if let highlightedView = self.highlightedMenuItems.removeValue(forKey: key)?.view { + (highlightedView as? MenuCardHighlighting)?.setHighlighted(false) + } let isPersistentMenu = menu === self.mergedMenu || menu === self.fallbackMenu || @@ -145,20 +167,40 @@ extension StatusItemController { if !isPersistentMenu { self.menuProviders.removeValue(forKey: key) self.menuVersions.removeValue(forKey: key) + } else if self.menuNeedsRefresh(menu) { + self.rebuildClosedMenuIfNeeded(menu) } - for menuItem in menu.items { - (menuItem.view as? MenuCardHighlighting)?.setHighlighted(false) - } + self.parentMenuRebuildsDeferredDuringTracking.remove(key) + self.scheduleDeferredMenuInteractionRefreshIfNeeded() } func menu(_ menu: NSMenu, willHighlight item: NSMenuItem?) { - for menuItem in menu.items { - let highlighted = menuItem == item && menuItem.isEnabled - (menuItem.view as? MenuCardHighlighting)?.setHighlighted(highlighted) + let key = ObjectIdentifier(menu) + let previous = self.highlightedMenuItems[key] + guard previous !== item else { return } + + if let previous { + (previous.view as? MenuCardHighlighting)?.setHighlighted(false) + } + + if let item, item.isEnabled { + self.highlightedMenuItems[key] = item + (item.view as? MenuCardHighlighting)?.setHighlighted(true) + } else { + self.highlightedMenuItems.removeValue(forKey: key) } } func populateMenu(_ menu: NSMenu, provider: UsageProvider?) { + let populateStartedAt = CACurrentMediaTime() + defer { + self.logMenuOperationDurationIfSlow( + "populateMenu", + startedAt: populateStartedAt, + menu: menu, + provider: provider) + } + let enabledProviders = self.store.enabledProvidersForDisplay() let includesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) let switcherSelection = self.shouldMergeIcons && enabledProviders.count > 1 @@ -202,6 +244,7 @@ extension StatusItemController { let switcherUsageBarsShowUsedMatch = self.settings.usageBarsShowUsed == self.lastSwitcherUsageBarsShowUsed let switcherSelectionMatches = switcherSelection == self.lastMergedSwitcherSelection let switcherOverviewAvailabilityMatches = includesOverview == self.lastSwitcherIncludesOverview + let menuLocalizationMatches = self.menuLocalizationSignature() == self.lastMenuLocalizationSignature let tokenSwitcherCompatible = tokenAccountDisplay == self.lastTokenAccountMenuDisplay && ((tokenAccountDisplay?.showSwitcher == true && hasTokenSwitcher) || (tokenAccountDisplay?.showSwitcher != true && !hasTokenSwitcher)) @@ -222,6 +265,7 @@ extension StatusItemController { switcherUsageBarsShowUsedMatch && switcherSelectionMatches && switcherOverviewAvailabilityMatches && + menuLocalizationMatches && tokenSwitcherCompatible && codexSwitcherCompatible && reusableRowWidthsMatch && @@ -259,6 +303,7 @@ extension StatusItemController { switcherProvidersMatch && switcherUsageBarsShowUsedMatch && switcherOverviewAvailabilityMatches && + menuLocalizationMatches && providerSwitcherWidthMatches && !menu.items.isEmpty && menu.items.first?.view is ProviderSwitcherView @@ -309,10 +354,9 @@ extension StatusItemController { guard !menu.items.isEmpty else { return [] } var reusableRows: [NSMenuItem] = [] - var index = 0 - if menu.items.first?.view is ProviderSwitcherView { + var index = self.providerSwitcherContentStartIndex(in: menu) + if index > 0 { reusableRows.append(menu.items[0]) - index = 2 } if menu.items.count > index, menu.items[index].view is CodexAccountSwitcherView @@ -345,7 +389,7 @@ extension StatusItemController { context: MenuUpdateContext) { self.performMenuMutationWithoutAnimation { - let contentStartIndex = menu.items.first?.view is ProviderSwitcherView ? 2 : 0 + let contentStartIndex = self.providerSwitcherContentStartIndex(in: menu) if let switcherView = menu.items.first?.view as? ProviderSwitcherView { switcherView.updateSelection(context.switcherSelection) switcherView.updateQuotaIndicators() @@ -354,11 +398,8 @@ extension StatusItemController { menu.removeItem(at: contentStartIndex) } - self.lastMergedSwitcherSelection = context.switcherSelection let enabledProviders = self.store.enabledProvidersForDisplay() - self.lastSwitcherProviders = enabledProviders - self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed - self.lastSwitcherIncludesOverview = self.includesOverviewTab(enabledProviders: enabledProviders) + self.rememberMergedSwitcherState(enabledProviders, context.switcherSelection) self.addCodexAccountSwitcherIfNeeded( to: menu, display: context.codexAccountDisplay, @@ -406,10 +447,10 @@ extension StatusItemController { width: context.menuWidth) // Track which providers the switcher was built with for smart update detection if self.shouldMergeIcons, context.enabledProviders.count > 1 { - self.lastSwitcherProviders = context.enabledProviders - self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed - self.lastMergedSwitcherSelection = context.switcherSelection - self.lastSwitcherIncludesOverview = context.includesOverview + self.rememberMergedSwitcherState( + context.enabledProviders, + context.switcherSelection, + context.includesOverview) } self.addCodexAccountSwitcherIfNeeded( to: menu, @@ -538,7 +579,9 @@ extension StatusItemController { let resolvedProviders = self.settings.resolvedMergedOverviewProviders( activeProviders: enabledProviders, maxVisibleProviders: Self.maxOverviewProviders) - let message = resolvedProviders.isEmpty ? "No providers selected for Overview." : "No overview data available." + let message = resolvedProviders.isEmpty + ? L("No providers selected for Overview.") + : L("No overview data available.") let item = NSMenuItem(title: message, action: nil, keyEquivalent: "") item.isEnabled = false item.representedObject = "overviewEmptyState" @@ -732,9 +775,10 @@ extension StatusItemController { } menu.addItem(item) case let .action(title, action): + let localizedTitle = L(title) if self.usesPersistentMenuActionItem(for: action) { menu.addItem(self.makePersistentMenuActionItem( - title: title, + title: localizedTitle, action: action, menu: menu, width: width)) @@ -742,7 +786,7 @@ extension StatusItemController { } let (selector, represented) = self.selector(for: action) - let item = NSMenuItem(title: title, action: selector, keyEquivalent: "") + let item = NSMenuItem(title: localizedTitle, action: selector, keyEquivalent: "") item.target = self item.representedObject = represented if let shortcut = self.shortcut(for: action) { @@ -760,12 +804,12 @@ extension StatusItemController { let subtitle = self.switchAccountSubtitle(for: targetProvider) { item.isEnabled = false - self.applySubtitle(subtitle, to: item, title: title) + self.applySubtitle(subtitle, to: item, title: localizedTitle) } else if case .addCodexAccount = action, let subtitle = self.codexAddAccountSubtitle() { item.isEnabled = false - self.applySubtitle(subtitle, to: item, title: title) + self.applySubtitle(subtitle, to: item, title: localizedTitle) } menu.addItem(item) case let .submenu(title, systemImageName, submenuItems): @@ -875,6 +919,7 @@ extension StatusItemController { textField.translatesAutoresizingMaskIntoConstraints = false container.addSubview(textField) + // macos-smell:disable MACOS005 NSLayoutConstraint.activate([ textField.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), textField.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10), @@ -1052,16 +1097,6 @@ extension StatusItemController { return .provider(self.resolvedMenuProvider(enabledProviders: enabledProviders) ?? .codex) } - func menuNeedsRefresh(_ menu: NSMenu) -> Bool { - let key = ObjectIdentifier(menu) - return self.menuVersions[key] != self.menuContentVersion - } - - func markMenuFresh(_ menu: NSMenu) { - let key = ObjectIdentifier(menu) - self.menuVersions[key] = self.menuContentVersion - } - func menuProvider(for menu: NSMenu) -> UsageProvider? { if self.shouldMergeIcons { return self.resolvedMenuProvider() @@ -1075,51 +1110,17 @@ extension StatusItemController { return self.store.enabledProvidersForDisplay().first ?? .codex } - func hasOpenHostedSubviewMenu() -> Bool { - self.openMenus.values.contains { self.isHostedSubviewMenu($0) } - } - - func refreshOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { - self.rebuildOpenMenuIfStillVisible(menu, provider: provider) - Task { @MainActor [weak self, weak menu] in - guard let self, let menu else { return } - #if DEBUG - if let override = self._test_openMenuRefreshYieldOverride { - await override() - } else { - await Task.yield() - } - #else - await Task.yield() - #endif - self.rebuildOpenMenuIfStillVisible(menu, provider: provider) - } - } - - func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { - guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } - guard self.isHostedSubviewMenu(menu) || !self.hasOpenHostedSubviewMenu() else { return } - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) - self.applyIcon(phase: nil) - #if DEBUG - self._test_openMenuRebuildObserver?(menu) - #endif - } - private func scheduleOpenMenuRefresh(for menu: NSMenu) { - // Kick off a refresh on open (non-forced) and re-check after a delay. - // NEVER block menu opening with network requests. - if !self.store.isRefreshing { - self.refreshStore(forceTokenUsage: false, refreshOpenMenusWhenComplete: false) - } + // Queue refresh work until the menu closes. AppKit menu tracking is modal; starting provider refreshes + // while it is active can make the menu feel frozen and can block keyboard focus from returning. + self.deferMenuInteractionRefreshIfNeeded() let key = ObjectIdentifier(menu) self.menuRefreshTasks[key]?.cancel() self.menuRefreshTasks[key] = Task { @MainActor [weak self, weak menu] in guard let self, let menu else { return } try? await Task.sleep(for: Self.menuOpenRefreshDelay) guard !Task.isCancelled else { return } - guard Self.menuRefreshEnabled else { return } + guard self.isMenuRefreshEnabled else { return } #if DEBUG self.onDelayedMenuRefreshAttemptForTesting?() #endif @@ -1130,7 +1131,7 @@ extension StatusItemController { let retryMissingSnapshotCount = retryProviders.count { self.store.snapshot(for: $0) == nil } let willRetryRefresh = retryStaleProviderCount > 0 || retryMissingSnapshotCount > 0 guard willRetryRefresh else { return } - self.refreshStore(forceTokenUsage: false, refreshOpenMenusWhenComplete: false) + self.deferMenuInteractionRefreshIfNeeded() } } @@ -1420,7 +1421,10 @@ extension StatusItemController { } private func makeBuyCreditsItem() -> NSMenuItem { - let item = NSMenuItem(title: "Buy Credits...", action: #selector(self.openCreditsPurchase), keyEquivalent: "") + let item = NSMenuItem( + title: L("Buy Credits..."), + action: #selector(self.openCreditsPurchase), + keyEquivalent: "") item.target = self if let image = NSImage(systemSymbolName: "plus.circle", accessibilityDescription: nil) { image.isTemplate = true @@ -1434,7 +1438,7 @@ extension StatusItemController { private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeCreditsHistorySubmenu(width: self.renderedMenuWidth(for: menu)) else { return false } - let item = NSMenuItem(title: "Credits history", action: nil, keyEquivalent: "") + let item = NSMenuItem(title: L("Credits history"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -1445,7 +1449,7 @@ extension StatusItemController { private func addUsageBreakdownSubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeUsageBreakdownSubmenu(width: self.renderedMenuWidth(for: menu)) else { return false } - let item = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") + let item = NSMenuItem(title: L("Usage breakdown"), action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu menu.addItem(item) @@ -1457,7 +1461,7 @@ extension StatusItemController { guard let submenu = self.makeCostHistorySubmenu(provider: provider, width: self.renderedMenuWidth(for: menu)) else { return false } let days = self.store.settings.costUsageHistoryDays - let title = days == 1 ? "Usage history (today)" : "Usage history (\(days) days)" + let title = days == 1 ? L("Usage history (today)") : String(format: L("Usage history (%d days)"), days) let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") item.isEnabled = true item.submenu = submenu @@ -1489,7 +1493,7 @@ extension StatusItemController { let submenu = NSMenu() submenu.delegate = self - let titleItem = NSMenuItem(title: "MCP details", action: nil, keyEquivalent: "") + let titleItem = NSMenuItem(title: L("MCP details"), action: nil, keyEquivalent: "") titleItem.isEnabled = false submenu.addItem(titleItem) @@ -1541,8 +1545,8 @@ extension StatusItemController { } func makeCostHistorySubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { - guard [.codex, .claude, .vertexai, .bedrock].contains(provider) else { return nil } - guard self.store.tokenSnapshot(for: provider)?.daily.isEmpty == false else { return nil } + guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { return nil } + guard self.tokenSnapshotForCostHistorySubmenu(provider: provider)?.daily.isEmpty == false else { return nil } if let width { return self.makeHostedSubviewPlaceholderMenu( chartID: Self.costHistoryChartID, @@ -1552,19 +1556,23 @@ extension StatusItemController { return self.makeHostedSubviewPlaceholderMenu(chartID: Self.costHistoryChartID, provider: provider) } + func tokenSnapshotForCostHistorySubmenu(provider: UsageProvider) -> CostUsageTokenSnapshot? { + let projected = self.store.tokenSnapshot( + fromProviderSnapshot: self.store.snapshot(for: provider), + provider: provider) + if UsageStore.tokenCostRequiresProviderSnapshot(provider) { + return projected + } + return projected ?? self.store.tokenSnapshot(for: provider) + } + func makeOpenAIAPIUsageSubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { guard self.hasOpenAIAPIUsageSubmenu(provider: provider) else { return nil } - if let width { - return self.makeHostedSubviewPlaceholderMenu( - chartID: Self.openAIAPIUsageChartID, - provider: provider, - width: width) - } - return self.makeHostedSubviewPlaceholderMenu(chartID: Self.openAIAPIUsageChartID, provider: provider) + return self.makeCostHistorySubmenu(provider: provider, width: width) } private func hasOpenAIAPIUsageSubmenu(provider: UsageProvider) -> Bool { - provider == .openai && self.store.snapshot(for: provider)?.openAIAPIUsage?.daily.isEmpty == false + provider == .openai && self.tokenSnapshotForCostHistorySubmenu(provider: provider)?.daily.isEmpty == false } func makeStorageBreakdownSubmenu(provider: UsageProvider, width: CGFloat? = nil) -> NSMenu? { diff --git a/Sources/CodexBar/StatusItemController+MenuCardModel.swift b/Sources/CodexBar/StatusItemController+MenuCardModel.swift index 6ecaaef7..1518d122 100644 --- a/Sources/CodexBar/StatusItemController+MenuCardModel.swift +++ b/Sources/CodexBar/StatusItemController+MenuCardModel.swift @@ -25,6 +25,10 @@ extension StatusItemController { } else { snapshotOverride ?? self.store.snapshot(for: target) } + let projectedTokenSnapshot = self.store.tokenSnapshot(fromProviderSnapshot: snapshot, provider: target) + let storedTokenSnapshot = UsageStore.tokenCostRequiresProviderSnapshot(target) + ? nil + : self.store.tokenSnapshot(for: target) let now = Date() let codexProjection = self.store.codexConsumerProjectionIfNeeded( for: target, @@ -44,25 +48,27 @@ extension StatusItemController { dashboard = nil dashboardError = codexProjection.userFacingErrors.dashboard if surface == .liveCard { - tokenSnapshot = self.store.tokenSnapshot(for: target) + tokenSnapshot = projectedTokenSnapshot ?? storedTokenSnapshot tokenError = self.store.tokenError(for: target) } else { - tokenSnapshot = nil + tokenSnapshot = projectedTokenSnapshot tokenError = nil } - } else if target == .claude || target == .vertexai || target == .bedrock, snapshotOverride == nil { + } else if ProviderDescriptorRegistry.descriptor(for: target).tokenCost.supportsTokenCost, + snapshotOverride == nil + { credits = nil creditsError = nil dashboard = nil dashboardError = nil - tokenSnapshot = self.store.tokenSnapshot(for: target) + tokenSnapshot = projectedTokenSnapshot ?? storedTokenSnapshot tokenError = self.store.tokenError(for: target) } else { credits = nil creditsError = nil dashboard = nil dashboardError = nil - tokenSnapshot = nil + tokenSnapshot = projectedTokenSnapshot tokenError = nil } @@ -107,6 +113,7 @@ extension StatusItemController { .session: self.quotaWarningMarkerThresholds(provider: target, window: .session), .weekly: self.quotaWarningMarkerThresholds(provider: target, window: .weekly), ], + workDaysPerWeek: self.settings.weeklyProgressWorkDays, now: now) return UsageMenuCardView.Model.make(input) } diff --git a/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift new file mode 100644 index 00000000..d5d0ff1c --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuInteractionRefresh.swift @@ -0,0 +1,124 @@ +import AppKit +import CodexBarCore +import QuartzCore + +extension StatusItemController { + private static let defaultDeferredMenuInteractionRefreshDelay: Duration = .milliseconds(250) + private static let slowMenuOperationThreshold: TimeInterval = 0.15 + + #if DEBUG + private static var deferredMenuInteractionRefreshDelayForTesting: Duration = .milliseconds(250) + + static func setDeferredMenuInteractionRefreshDelayForTesting(_ delay: Duration) { + self.deferredMenuInteractionRefreshDelayForTesting = delay + } + + static func resetDeferredMenuInteractionRefreshDelayForTesting() { + self.deferredMenuInteractionRefreshDelayForTesting = self.defaultDeferredMenuInteractionRefreshDelay + } + #endif + + private static var deferredMenuInteractionRefreshDelay: Duration { + #if DEBUG + deferredMenuInteractionRefreshDelayForTesting + #else + defaultDeferredMenuInteractionRefreshDelay + #endif + } + + func logMenuOperationDurationIfSlow( + _ operation: String, + startedAt: CFTimeInterval, + menu: NSMenu, + provider: UsageProvider?) + { + let elapsed = CACurrentMediaTime() - startedAt + guard elapsed >= Self.slowMenuOperationThreshold else { return } + self.menuLogger.warning( + "slow menu operation", + metadata: [ + "operation": operation, + "durationMs": String(format: "%.1f", elapsed * 1000), + "items": "\(menu.items.count)", + "provider": provider?.rawValue ?? "nil", + "openMenus": "\(self.openMenus.count)", + "storeRefreshing": self.store.isRefreshing ? "1" : "0", + ]) + } + + func deferMenuInteractionRefreshIfNeeded() { + guard !self.store.isRefreshing else { return } + self.deferredMenuInteractionRefreshPending = true + } + + func deferOpenAIDashboardRefreshUntilMenuCloses(reason: String) { + if let existingReason = self.deferredOpenAIDashboardRefreshReason { + self.deferredOpenAIDashboardRefreshReason = "\(existingReason), \(reason)" + } else { + self.deferredOpenAIDashboardRefreshReason = reason + } + } + + func cancelDeferredMenuInteractionRefreshTask() { + self.deferredMenuInteractionRefreshTask?.cancel() + self.deferredMenuInteractionRefreshTask = nil + } + + func scheduleDeferredMenuInteractionRefreshIfNeeded(delay: Duration? = nil) { + guard self.openMenus.isEmpty else { return } + guard self.deferredMenuInteractionRefreshPending || self.deferredOpenAIDashboardRefreshReason != nil else { + return + } + guard !self.hasPreparedForAppShutdown else { return } + + self.cancelDeferredMenuInteractionRefreshTask() + let delay = delay ?? Self.deferredMenuInteractionRefreshDelay + self.deferredMenuInteractionRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + guard let self, !Task.isCancelled else { return } + guard self.openMenus.isEmpty else { + self.deferredMenuInteractionRefreshTask = nil + return + } + let shouldRefreshStore = self.deferredMenuInteractionRefreshPending + let openAIDashboardRefreshReason = self.deferredOpenAIDashboardRefreshReason + guard shouldRefreshStore || openAIDashboardRefreshReason != nil else { + self.deferredMenuInteractionRefreshTask = nil + return + } + guard !self.hasPreparedForAppShutdown else { + self.deferredMenuInteractionRefreshTask = nil + return + } + guard !self.store.isRefreshing else { + self.deferredMenuInteractionRefreshTask = nil + self + .scheduleDeferredMenuInteractionRefreshIfNeeded(delay: Self + .defaultDeferredMenuInteractionRefreshDelay) + return + } + self.deferredMenuInteractionRefreshTask = nil + self.deferredMenuInteractionRefreshPending = false + self.deferredOpenAIDashboardRefreshReason = nil + #if DEBUG + self.onDeferredMenuInteractionRefreshForTesting?() + #endif + if shouldRefreshStore { + await self.performStoreRefresh( + forceTokenUsage: false, + refreshOpenMenusWhenComplete: false, + interaction: .background) + guard !Task.isCancelled else { return } + } + if let openAIDashboardRefreshReason { + guard self.openMenus.isEmpty else { + self.deferOpenAIDashboardRefreshUntilMenuCloses(reason: openAIDashboardRefreshReason) + return + } + // Keep menu-originated automatic dashboard refreshes non-interactive: + // opening a menu is not consent to show macOS Keychain prompts. + self.store.requestOpenAIDashboardRefreshIfStale(reason: openAIDashboardRefreshReason) + } + } + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuLocalization.swift b/Sources/CodexBar/StatusItemController+MenuLocalization.swift new file mode 100644 index 00000000..52944810 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuLocalization.swift @@ -0,0 +1,36 @@ +import CodexBarCore + +extension StatusItemController { + func menuLocalizationSignature() -> String { + [ + codexBarLocalizationSignature(), + L("Overview"), + L("Cost"), + ].joined(separator: "|") + } + + func rememberMergedSwitcherState(_ providers: [UsageProvider], _ selection: ProviderSwitcherSelection?) { + self.rememberMergedSwitcherState( + providers, + selection, + self.includesOverviewTab(for: providers)) + } + + func rememberMergedSwitcherState( + _ providers: [UsageProvider], + _ selection: ProviderSwitcherSelection?, + _ includesOverview: Bool) + { + self.lastSwitcherProviders = providers + self.lastSwitcherUsageBarsShowUsed = self.settings.usageBarsShowUsed + self.lastMergedSwitcherSelection = selection + self.lastSwitcherIncludesOverview = includesOverview + self.lastMenuLocalizationSignature = self.menuLocalizationSignature() + } + + private func includesOverviewTab(for providers: [UsageProvider]) -> Bool { + !self.settings.resolvedMergedOverviewProviders( + activeProviders: providers, + maxVisibleProviders: SettingsStore.mergedOverviewProviderLimit).isEmpty + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift index c94561b0..4742e30e 100644 --- a/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift +++ b/Sources/CodexBar/StatusItemController+MenuRefreshScheduling.swift @@ -3,6 +3,94 @@ import CodexBarCore import QuartzCore extension StatusItemController { + func didMenuAdjunctReadinessChange() -> Bool { + let signature = self.menuAdjunctReadinessSignature() + defer { self.lastMenuAdjunctReadinessSignature = signature } + return signature != self.lastMenuAdjunctReadinessSignature + } + + func menuAdjunctReadinessSignature() -> String { + let dashboard = self.store.openAIDashboard + let dashboardUsageBreakdown = OpenAIDashboardDailyBreakdown.removingSkillUsageServices( + from: dashboard?.usageBreakdown ?? []) + var parts = [ + "costEnabled=\(self.settings.costUsageEnabled ? "1" : "0")", + "openAIAttached=\(self.store.openAIDashboardAttachmentAuthorized ? "1" : "0")", + "openAILogin=\(self.store.openAIDashboardRequiresLogin ? "1" : "0")", + "openAIUpdated=\(Self.millisecondsSinceEpoch(dashboard?.updatedAt))", + "openAIDaily=\(Self.dashboardBreakdownReadinessSignature(dashboard?.dailyBreakdown ?? []))", + "openAIUsage=\(Self.dashboardBreakdownReadinessSignature(dashboardUsageBreakdown))", + "credits=\(self.store.credits == nil ? "0" : "1")", + "planHistoryRevision=\(self.store.planUtilizationHistoryRevision)", + ] + + for provider in self.store.enabledProvidersForDisplay() { + let tokenSignature = self.tokenSnapshotReadinessSignature(for: provider) + let usageHistoryVisible = self.store.supportsPlanUtilizationHistory(for: provider) && + !self.store.shouldHidePlanUtilizationMenuItem(for: provider) + parts.append( + [ + provider.rawValue, + "token=\(tokenSignature)", + "usageHistory=\(usageHistoryVisible ? "1" : "0")", + ].joined(separator: ":")) + } + + return parts.joined(separator: "|") + } + + private static func dashboardBreakdownReadinessSignature( + _ breakdown: [OpenAIDashboardDailyBreakdown]) -> String + { + breakdown + .map { day in + let services = day.services + .map { "\($0.service)=\(Self.formatDoubleForSignature($0.creditsUsed))" } + .joined(separator: ",") + return [ + day.day, + Self.formatDoubleForSignature(day.totalCreditsUsed), + services, + ].joined(separator: ":") + } + .joined(separator: ";") + } + + private func tokenSnapshotReadinessSignature(for provider: UsageProvider) -> String { + guard let snapshot = self.store.tokenSnapshot(for: provider) else { return "none" } + let daily = snapshot.daily + .map { entry in + [ + entry.date, + "\(entry.totalTokens ?? -1)", + Self.formatOptionalDoubleForSignature(entry.costUSD), + ].joined(separator: ",") + } + .joined(separator: ";") + return [ + "sessionTokens=\(snapshot.sessionTokens ?? -1)", + "sessionCost=\(Self.formatOptionalDoubleForSignature(snapshot.sessionCostUSD))", + "lastTokens=\(snapshot.last30DaysTokens ?? -1)", + "lastCost=\(Self.formatOptionalDoubleForSignature(snapshot.last30DaysCostUSD))", + "updated=\(Int(snapshot.updatedAt.timeIntervalSince1970 * 1000))", + "daily=\(daily)", + ].joined(separator: ",") + } + + private static func millisecondsSinceEpoch(_ date: Date?) -> Int { + guard let date else { return -1 } + return Int(date.timeIntervalSince1970 * 1000) + } + + private static func formatOptionalDoubleForSignature(_ value: Double?) -> String { + guard let value else { return "nil" } + return self.formatDoubleForSignature(value) + } + + private static func formatDoubleForSignature(_ value: Double) -> String { + String(format: "%.8f", value) + } + func performMenuMutationWithoutAnimation(_ updates: () -> Void) { CATransaction.begin() CATransaction.setDisableActions(true) @@ -13,12 +101,56 @@ extension StatusItemController { func deferSwitcherMenuRebuildIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { self.providerSwitcherUpdateToken &+= 1 let updateToken = self.providerSwitcherUpdateToken - Task { @MainActor [weak self, weak menu] in - await Task.yield() + self.scheduleOpenMenuRebuildIfStillVisible( + menu, + provider: provider, + closeHostedSubviewMenusBeforeRebuild: true) + { [weak self] in + guard let self else { return false } + return self.providerSwitcherUpdateToken == updateToken + } + } + + func scheduleOpenMenuRebuildIfStillVisible( + _ menu: NSMenu, + provider: UsageProvider?, + closeHostedSubviewMenusBeforeRebuild: Bool = false, + beforeRebuild: (@MainActor () -> Bool)? = nil) + { + let key = ObjectIdentifier(menu) + if closeHostedSubviewMenusBeforeRebuild { + self.openMenuRebuildsClosingHostedSubviewMenus.insert(key) + } + let shouldCloseHostedSubviewMenus = self.openMenuRebuildsClosingHostedSubviewMenus.contains(key) + self.openMenuRebuildTokenCounter &+= 1 + let rebuildToken = self.openMenuRebuildTokenCounter + self.openMenuRebuildTokens[key] = rebuildToken + self.openMenuRebuildTasks[key]?.cancel() + self.openMenuRebuildTasks[key] = Task { @MainActor [weak self, weak menu] in guard let self, let menu else { return } - guard self.providerSwitcherUpdateToken == updateToken else { return } - guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } - self.closeHostedSubviewMenusForParentSwitch() + #if DEBUG + if let override = self._test_openMenuRefreshYieldOverride { + await override() + } else { + await Task.yield() + } + #else + await Task.yield() + #endif + guard !Task.isCancelled else { return } + guard self.openMenuRebuildTokens[key] == rebuildToken else { return } + defer { + if self.openMenuRebuildTokens[key] == rebuildToken { + self.openMenuRebuildTasks.removeValue(forKey: key) + self.openMenuRebuildTokens.removeValue(forKey: key) + self.openMenuRebuildsClosingHostedSubviewMenus.remove(key) + } + } + guard self.openMenus[key] != nil else { return } + guard beforeRebuild?() ?? true else { return } + if shouldCloseHostedSubviewMenus { + self.closeHostedSubviewMenusForParentSwitch() + } self.rebuildOpenMenuIfStillVisible(menu, provider: provider) } } diff --git a/Sources/CodexBar/StatusItemController+MenuTracking.swift b/Sources/CodexBar/StatusItemController+MenuTracking.swift index 033710b0..625dbdf4 100644 --- a/Sources/CodexBar/StatusItemController+MenuTracking.swift +++ b/Sources/CodexBar/StatusItemController+MenuTracking.swift @@ -1,13 +1,76 @@ import AppKit +import CodexBarCore extension StatusItemController { + func invalidateMenus( + refreshOpenMenus: Bool = false, + deferOpenParentMenuRebuild: Bool = false) + { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif + self.menuContentVersion &+= 1 + guard self.isMenuRefreshEnabled else { return } + if !self.openMenus.isEmpty { + guard refreshOpenMenus else { return } + self.refreshOpenMenusAllowingParentRebuild( + deferParentRebuildDuringTracking: deferOpenParentMenuRebuild) + self.scheduleOpenMenuInvalidationRetry( + deferParentRebuildDuringTracking: deferOpenParentMenuRebuild) + return + } + } + func renderedMenuWidth(for menu: NSMenu) -> CGFloat { let measuredWidth = ceil(menu.size.width) return max(measuredWidth, Self.menuCardBaseWidth) } + func rebuildClosedMenuIfNeeded(_ menu: NSMenu) { + guard !self.hasPreparedForAppShutdown else { return } + let provider = self.menuProvider(for: menu) + Task { @MainActor [weak self, weak menu] in + await Task.yield() + guard let self, let menu else { return } + guard !self.hasPreparedForAppShutdown else { return } + guard self.openMenus[ObjectIdentifier(menu)] == nil else { return } + guard self.menuNeedsRefresh(menu) else { return } + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + } + } + + func menuNeedsRefresh(_ menu: NSMenu) -> Bool { + let key = ObjectIdentifier(menu) + return self.menuVersions[key] != self.menuContentVersion + } + + func markMenuFresh(_ menu: NSMenu) { + let key = ObjectIdentifier(menu) + self.menuVersions[key] = self.menuContentVersion + } + + func hasOpenHostedSubviewMenu() -> Bool { + self.openMenus.values.contains { self.isHostedSubviewMenu($0) } + } + + func refreshOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { + self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) + } + + func rebuildOpenMenuIfStillVisible(_ menu: NSMenu, provider: UsageProvider?) { + guard self.openMenus[ObjectIdentifier(menu)] != nil else { return } + guard self.isHostedSubviewMenu(menu) || !self.hasOpenHostedSubviewMenu() else { return } + self.populateMenu(menu, provider: provider) + self.markMenuFresh(menu) + self.applyIcon(phase: nil) + #if DEBUG + self._test_openMenuRebuildObserver?(menu) + #endif + } + func refreshOpenMenusIfNeeded() { - guard Self.menuRefreshEnabled else { return } + guard self.isMenuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } self.refreshOpenMenusIfNeeded(allowsParentRebuild: false) } @@ -16,13 +79,42 @@ extension StatusItemController { self.refreshOpenMenusAllowingParentRebuild() } - func refreshOpenMenusAllowingParentRebuild() { - guard Self.menuRefreshEnabled else { return } + func refreshOpenMenusAfterHostedSubviewClose() { + guard self.isMenuRefreshEnabled else { return } + guard !self.openMenus.isEmpty else { return } + self.refreshOpenMenusIfNeeded( + allowsParentRebuild: true, + respectsParentRebuildDeferral: true) + } + + func refreshOpenMenusAllowingParentRebuild(deferParentRebuildDuringTracking: Bool = false) { + guard self.isMenuRefreshEnabled else { return } guard !self.openMenus.isEmpty else { return } - self.refreshOpenMenusIfNeeded(allowsParentRebuild: true) + self.refreshOpenMenusIfNeeded( + allowsParentRebuild: true, + deferParentRebuildDuringTracking: deferParentRebuildDuringTracking) } - private func refreshOpenMenusIfNeeded(allowsParentRebuild: Bool) { + func scheduleOpenMenuInvalidationRetry(deferParentRebuildDuringTracking: Bool = false) { + self.openMenuInvalidationRetryTask?.cancel() + self.openMenuInvalidationRetryTask = Task { @MainActor [weak self] in + guard let self else { return } + await Task.yield() + guard !Task.isCancelled else { return } + #if DEBUG + self.onOpenMenuInvalidationRetryForTesting?() + #endif + self.refreshOpenMenusAllowingParentRebuild( + deferParentRebuildDuringTracking: deferParentRebuildDuringTracking) + self.openMenuInvalidationRetryTask = nil + } + } + + private func refreshOpenMenusIfNeeded( + allowsParentRebuild: Bool, + deferParentRebuildDuringTracking: Bool = false, + respectsParentRebuildDeferral: Bool = false) + { var orphanedKeys: [ObjectIdentifier] = [] let hasOpenHostedSubviewMenu = self.hasOpenHostedSubviewMenu() for (key, menu) in self.openMenus { @@ -33,6 +125,8 @@ extension StatusItemController { self.refreshOpenMenuIfNeeded( menu, allowsParentRebuild: allowsParentRebuild, + deferParentRebuildDuringTracking: deferParentRebuildDuringTracking, + respectsParentRebuildDeferral: respectsParentRebuildDeferral, hasOpenHostedSubviewMenu: hasOpenHostedSubviewMenu) } self.removeOrphanedOpenMenuEntries(orphanedKeys) @@ -41,19 +135,30 @@ extension StatusItemController { private func refreshOpenMenuIfNeeded( _ menu: NSMenu, allowsParentRebuild: Bool, + deferParentRebuildDuringTracking: Bool, + respectsParentRebuildDeferral: Bool, hasOpenHostedSubviewMenu: Bool) { if self.isHostedSubviewMenu(menu) { - self.refreshHostedSubviewHeights(in: menu) + self.refreshHostedSubviewMenu(menu) return } guard allowsParentRebuild else { return } - guard !hasOpenHostedSubviewMenu else { return } guard self.menuNeedsRefresh(menu) else { return } + let key = ObjectIdentifier(menu) + + if deferParentRebuildDuringTracking { + self.parentMenuRebuildsDeferredDuringTracking.insert(key) + return + } + if respectsParentRebuildDeferral, self.parentMenuRebuildsDeferredDuringTracking.contains(key) { + return + } + self.parentMenuRebuildsDeferredDuringTracking.remove(key) + guard !hasOpenHostedSubviewMenu else { return } let provider = self.menuProvider(for: menu) - self.populateMenu(menu, provider: provider) - self.markMenuFresh(menu) + self.scheduleOpenMenuRebuildIfStillVisible(menu, provider: provider) } private func removeOrphanedOpenMenuEntries(_ keys: [ObjectIdentifier]) { @@ -62,6 +167,7 @@ extension StatusItemController { self.menuRefreshTasks.removeValue(forKey: key)?.cancel() self.menuProviders.removeValue(forKey: key) self.menuVersions.removeValue(forKey: key) + self.parentMenuRebuildsDeferredDuringTracking.remove(key) } } } diff --git a/Sources/CodexBar/StatusItemController+MenuTypes.swift b/Sources/CodexBar/StatusItemController+MenuTypes.swift index 5e9cc13a..7c0f3b1b 100644 --- a/Sources/CodexBar/StatusItemController+MenuTypes.swift +++ b/Sources/CodexBar/StatusItemController+MenuTypes.swift @@ -34,7 +34,7 @@ struct OverviewMenuCardRowView: View { } if let storageText { HStack(alignment: .firstTextBaseline, spacing: 4) { - Text("Storage:") + Text("\(L("Storage")):") .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) Text(storageText) diff --git a/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift new file mode 100644 index 00000000..d227a9ae --- /dev/null +++ b/Sources/CodexBar/StatusItemController+ProviderSwitcher.swift @@ -0,0 +1,119 @@ +import AppKit +import CodexBarCore + +final class ProviderSwitcherShortcutEventMonitor { + private let events: NSEvent.EventTypeMask + private let callback: @MainActor (NSEvent) -> Bool + private let observer: CFRunLoopObserver + private var isActive = false + + init(events: NSEvent.EventTypeMask, callback: @escaping @MainActor (NSEvent) -> Bool) { + self.events = events + self.callback = callback + + self.observer = CFRunLoopObserverCreateWithHandler( + nil, + CFRunLoopActivity.beforeSources.rawValue, + true, + 0) + { [events, callback] _, _ in + MainActor.assumeIsolated { + while let event = NSApp.nextEvent( + matching: events, + until: .distantPast, + inMode: .eventTracking, + dequeue: false) + { + guard callback(event) else { break } + _ = NSApp.nextEvent( + matching: events, + until: .distantPast, + inMode: .eventTracking, + dequeue: true) + } + } + } + } + + deinit { + self.stop() + } + + func start() { + guard !self.isActive else { return } + CFRunLoopAddObserver( + RunLoop.main.getCFRunLoop(), + self.observer, + CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) + self.isActive = true + } + + func stop() { + guard self.isActive else { return } + CFRunLoopRemoveObserver( + RunLoop.main.getCFRunLoop(), + self.observer, + CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString)) + self.isActive = false + } +} + +extension StatusItemController { + func installProviderSwitcherShortcutMonitorIfNeeded(for menu: NSMenu) { + guard self.isMenuRefreshEnabled, + self.shouldMergeIcons, + menu.items.first?.view is ProviderSwitcherView + else { + return + } + + self.removeProviderSwitcherShortcutMonitor() + let monitor = ProviderSwitcherShortcutEventMonitor(events: [.keyDown]) { [weak self, weak menu] event in + guard let self, + let menu, + self.openMenus[ObjectIdentifier(menu)] != nil, + menu.items.first?.view is ProviderSwitcherView + else { + return false + } + + return self.handleProviderSwitcherShortcut(event, menu: menu) + } + monitor.start() + self.providerSwitcherShortcutEventMonitor = monitor + self.providerSwitcherShortcutMenuID = ObjectIdentifier(menu) + } + + func removeProviderSwitcherShortcutMonitor() { + self.providerSwitcherShortcutEventMonitor?.stop() + self.providerSwitcherShortcutEventMonitor = nil + self.providerSwitcherShortcutMenuID = nil + } + + func providerSwitcherContentStartIndex(in menu: NSMenu) -> Int { + menu.items.first?.view is ProviderSwitcherView ? 2 : 0 + } + + @discardableResult + func handleProviderSwitcherShortcut(_ event: NSEvent, menu: NSMenu) -> Bool { + if let index = StatusItemMenu.providerSelectionIndex(for: event) { + return self.selectProviderSwitcherSegment(at: index, menu: menu) + } + if let direction = StatusItemMenu.providerNavigationDirection(for: event) { + self.navigateProviderSwitcher(direction) + return true + } + return false + } + + @discardableResult + private func selectProviderSwitcherSegment(at index: Int, menu: NSMenu) -> Bool { + guard let switcherView = menu.items.first?.view as? ProviderSwitcherView, + switcherView.handleKeyboardSelection(at: index) + else { + return false + } + self.applyIcon(phase: nil) + return true + } +} diff --git a/Sources/CodexBar/StatusItemController+Shutdown.swift b/Sources/CodexBar/StatusItemController+Shutdown.swift new file mode 100644 index 00000000..4d50a9a0 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+Shutdown.swift @@ -0,0 +1,89 @@ +import AppKit + +extension StatusItemController { + func prepareForAppShutdown() { + guard !self.hasPreparedForAppShutdown else { return } + self.hasPreparedForAppShutdown = true + #if DEBUG + self.isReleasedForTesting = true + #endif + + let openMenus = Array(self.openMenus.values) + for menu in openMenus { + menu.cancelTrackingWithoutAnimation() + self.forgetClosedMenu(menu) + } + + self.cancelShutdownTasks() + self.clearShutdownMenuState() + self.removeShutdownStatusItems() + self.creditsPurchaseWindow?.close() + self.creditsPurchaseWindow = nil + } + + private func cancelShutdownTasks() { + self.blinkTask?.cancel() + self.blinkTask = nil + self.loginTask?.cancel() + self.loginTask = nil + self.screenChangeVisibilityTask?.cancel() + self.screenChangeVisibilityTask = nil + self.pendingScreenChangePreviousCount = nil + self.animationDriver?.stop() + self.animationDriver = nil + self.animationPhase = 0 + self.blinkForceUntil = nil + self.blinkStates.removeAll(keepingCapacity: false) + self.blinkAmounts.removeAll(keepingCapacity: false) + self.wiggleAmounts.removeAll(keepingCapacity: false) + self.tiltAmounts.removeAll(keepingCapacity: false) + self.quotaWarningFlashUntil.removeAll(keepingCapacity: false) + for task in self.quotaWarningFlashTasks.values { + task.cancel() + } + self.quotaWarningFlashTasks.removeAll(keepingCapacity: false) + + for task in self.menuRefreshTasks.values { + task.cancel() + } + for task in self.openMenuRebuildTasks.values { + task.cancel() + } + self.openMenuInvalidationRetryTask?.cancel() + self.openMenuInvalidationRetryTask = nil + } + + private func clearShutdownMenuState() { + self.removeProviderSwitcherShortcutMonitor() + self.menuRefreshTasks.removeAll(keepingCapacity: false) + self.openMenuRebuildTasks.removeAll(keepingCapacity: false) + self.openMenuRebuildTokens.removeAll(keepingCapacity: false) + self.openMenuRebuildsClosingHostedSubviewMenus.removeAll(keepingCapacity: false) + self.parentMenuRebuildsDeferredDuringTracking.removeAll(keepingCapacity: false) + self.openMenus.removeAll(keepingCapacity: false) + self.highlightedMenuItems.removeAll(keepingCapacity: false) + self.menuProviders.removeAll(keepingCapacity: false) + self.menuVersions.removeAll(keepingCapacity: false) + self.providerMenus.removeAll(keepingCapacity: false) + self.mergedMenu = nil + self.fallbackMenu = nil + } + + private func removeShutdownStatusItems() { + self.statusItem.menu = nil + self.statusBar.removeStatusItem(self.statusItem) + + for item in self.statusItems.values { + item.menu = nil + self.statusBar.removeStatusItem(item) + } + self.statusItems.removeAll(keepingCapacity: false) + self.lastAppliedProviderIconRenderSignatures.removeAll(keepingCapacity: false) + } + + #if DEBUG + func releaseStatusItemsForTesting() { + self.prepareForAppShutdown() + } + #endif +} diff --git a/Sources/CodexBar/StatusItemController+SwitcherViews.swift b/Sources/CodexBar/StatusItemController+SwitcherViews.swift index 550dafe8..a7fe4c68 100644 --- a/Sources/CodexBar/StatusItemController+SwitcherViews.swift +++ b/Sources/CodexBar/StatusItemController+SwitcherViews.swift @@ -79,7 +79,7 @@ final class ProviderSwitcherView: NSView { Segment( selection: .overview, image: overviewIcon, - title: "Overview"), + title: L("Overview")), at: 0) } self.segments = segments @@ -102,7 +102,7 @@ final class ProviderSwitcherView: NSView { maxAllowedSegmentWidth: initialMaxAllowedSegmentWidth, stackedIcons: self.stackedIcons) self.rowSpacing = self.stackedIcons ? 4 : 2 - self.rowHeight = Self.switcherRowHeight(stackedIcons: self.stackedIcons, rowCount: self.rowCount) + self.rowHeight = Self.switcherRowHeight(stackedIcons: self.stackedIcons) let height: CGFloat = self.rowHeight * CGFloat(self.rowCount) + self.rowSpacing * CGFloat(max(0, self.rowCount - 1)) self.preferredWidth = width @@ -311,6 +311,12 @@ final class ProviderSwitcherView: NSView { self.applySelection(at: pressedTag) } + func handleKeyboardSelection(at index: Int) -> Bool { + guard self.segments.indices.contains(index) else { return false } + self.applySelection(at: index) + return true + } + private func applySelection(at index: Int) { let selection = self.segments[index].selection self.updateSelection(selection) @@ -547,12 +553,8 @@ final class ProviderSwitcherView: NSView { return rows } - private static func switcherRowHeight(stackedIcons: Bool, rowCount: Int) -> CGFloat { - let baseRowHeight: CGFloat = if stackedIcons, rowCount >= 3 { - 40 - } else { - stackedIcons ? 36 : 30 - } + private static func switcherRowHeight(stackedIcons: Bool) -> CGFloat { + let baseRowHeight: CGFloat = stackedIcons ? 36 : 30 return baseRowHeight + self.quotaIndicatorReservedHeight } @@ -665,6 +667,14 @@ final class ProviderSwitcherView: NSView { self.buttons.map(\.fittingSize) } + func _test_rowCount() -> Int { + self.rowCount + } + + func _test_rowHeight() -> CGFloat { + self.rowHeight + } + func _test_setHoveredButtonTag(_ tag: Int?) { self.hoveredButtonTag = tag self.updateButtonStyles() diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 73233cb3..2e663921 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -14,7 +14,7 @@ extension StatusItemController { guard let submenu = self.makeUsageHistorySubmenu(provider: provider, width: width) else { return false } let item = self.makeMenuCardItem( HStack(spacing: 0) { - Text("Subscription Utilization") + Text(L("Subscription Utilization")) .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) @@ -55,6 +55,7 @@ extension StatusItemController { let chartItem = NSMenuItem() chartItem.isEnabled = true chartItem.representedObject = Self.usageHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } @@ -73,6 +74,7 @@ extension StatusItemController { chartItem.view = hosting chartItem.isEnabled = true chartItem.representedObject = Self.usageHistoryChartID + chartItem.toolTip = provider.rawValue submenu.addItem(chartItem) return true } diff --git a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift index ae341252..3751e1c6 100644 --- a/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift +++ b/Sources/CodexBar/StatusItemController+ZaiHourlyChartMenu.swift @@ -14,7 +14,7 @@ extension StatusItemController { let submenu = self.makeHostedSubviewPlaceholderMenu(chartID: Self.zaiHourlyUsageChartID, provider: provider) let item = self.makeMenuCardItem( HStack(spacing: 0) { - Text("Hourly Usage") + Text(L("Hourly Usage")) .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/CodexBar/StatusItemController.swift b/Sources/CodexBar/StatusItemController.swift index 6f7d2bf9..b8f4df3d 100644 --- a/Sources/CodexBar/StatusItemController.swift +++ b/Sources/CodexBar/StatusItemController.swift @@ -10,12 +10,15 @@ protocol StatusItemControlling: AnyObject { func openMenuFromShortcut() func runLoginFlowFromSettings(provider: UsageProvider) async func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? + func prepareForAppShutdown() } extension StatusItemControlling { func celebrationOriginPoint(for provider: UsageProvider?) -> CGPoint? { nil } + + func prepareForAppShutdown() {} } @MainActor @@ -25,6 +28,33 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin private static let defaultMenuRefreshEnabled = !SettingsStore.isRunningTests private(set) static var menuRefreshEnabled = !SettingsStore.isRunningTests static let quotaWarningFlashDuration: TimeInterval = 60 + private nonisolated static let statusItemAccessibilityTitle = "CodexBar" + private nonisolated static let statusItemAccessibilityIdentifierPrefix = "CodexBar.StatusItem" + private nonisolated static let mergedLegacyDefaultItemIndex = 0 + + enum StatusItemIdentity { + case merged + case provider(UsageProvider) + + var autosaveName: String { + switch self { + case .merged: + "codexbar-merged" + case let .provider(provider): + "codexbar-\(provider.rawValue)" + } + } + + var accessibilityIdentifier: String { + switch self { + case .merged: + StatusItemController.statusItemAccessibilityIdentifierPrefix + case let .provider(provider): + "\(StatusItemController.statusItemAccessibilityIdentifierPrefix).\(provider.rawValue)" + } + } + } + #if DEBUG static func setMenuRefreshEnabledForTesting(_ enabled: Bool) { self.menuRefreshEnabled = enabled @@ -34,6 +64,20 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.menuRefreshEnabled = self.defaultMenuRefreshEnabled } #endif + + #if DEBUG + var menuRefreshEnabledOverrideForTesting: Bool? + #endif + + var isMenuRefreshEnabled: Bool { + #if DEBUG + if let menuRefreshEnabledOverrideForTesting { + return menuRefreshEnabledOverrideForTesting + } + #endif + return Self.menuRefreshEnabled + } + typealias Factory = @MainActor ( UsageStore, @@ -75,20 +119,36 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin let updater: UpdaterProviding let managedCodexAccountCoordinator: ManagedCodexAccountCoordinator let codexAccountPromotionCoordinator: CodexAccountPromotionCoordinator - private let statusBar: NSStatusBar + let statusBar: NSStatusBar var statusItem: NSStatusItem var statusItems: [UsageProvider: NSStatusItem] = [:] var lastMenuProvider: UsageProvider? var menuProviders: [ObjectIdentifier: UsageProvider] = [:] var menuContentVersion: Int = 0 var menuVersions: [ObjectIdentifier: Int] = [:] + var lastMenuAdjunctReadinessSignature = "" var mergedMenu: NSMenu? var providerMenus: [UsageProvider: NSMenu] = [:] var fallbackMenu: NSMenu? var openMenus: [ObjectIdentifier: NSMenu] = [:] var menuRefreshTasks: [ObjectIdentifier: Task] = [:] + var openMenuRebuildTasks: [ObjectIdentifier: Task] = [:] + var openMenuRebuildTokens: [ObjectIdentifier: Int] = [:] + var openMenuRebuildTokenCounter = 0 + var openMenuRebuildsClosingHostedSubviewMenus: Set = [] + var parentMenuRebuildsDeferredDuringTracking: Set = [] + var deferredMenuInteractionRefreshPending = false + var deferredOpenAIDashboardRefreshReason: String? + var deferredMenuInteractionRefreshTask: Task? + var highlightedMenuItems: [ObjectIdentifier: NSMenuItem] = [:] + var providerSwitcherShortcutEventMonitor: ProviderSwitcherShortcutEventMonitor? + var providerSwitcherShortcutMenuID: ObjectIdentifier? + var hasPreparedForAppShutdown = false + var openMenuInvalidationRetryTask: Task? #if DEBUG var onDelayedMenuRefreshAttemptForTesting: (() -> Void)? + var onDeferredMenuInteractionRefreshForTesting: (() -> Void)? + var onOpenMenuInvalidationRetryForTesting: (() -> Void)? var isReleasedForTesting = false var _test_openMenuRefreshYieldOverride: (@MainActor () async -> Void)? var _test_openMenuRebuildObserver: (@MainActor (NSMenu) -> Void)? @@ -141,6 +201,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin /// Tracks whether the merged-menu switcher was built with the Overview tab visible. /// Used to force switcher rebuilds when Overview availability toggles. var lastSwitcherIncludesOverview: Bool = false + /// Tracks localization-sensitive labels used by the merged menu. + /// Used to force menu rebuilds when app language changes. + var lastMenuLocalizationSignature: String = "" /// Tracks which providers the merged menu's switcher was built with, to detect when it needs full rebuild. var lastSwitcherProviders: [UsageProvider] = [] /// Tracks which switcher tab state was used for the current merged-menu switcher instance. @@ -166,10 +229,26 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin set { self.settings.selectedMenuProvider = newValue } } - private static func makeStatusItem(statusBar: NSStatusBar) -> NSStatusItem { + private static func makeStatusItem( + statusBar: NSStatusBar, + identity: StatusItemIdentity, + defaults: UserDefaults, + legacyDefaultItemIndex: Int?) + -> NSStatusItem + { + MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: identity.autosaveName, + legacyDefaultItemIndex: legacyDefaultItemIndex) let item = statusBar.statusItem(withLength: NSStatusItem.variableLength) - // Ensure the icon is rendered at 1:1 without resampling (crisper edges for template images). - item.button?.imageScaling = .scaleNone + item.autosaveName = identity.autosaveName + if let button = item.button { + // Ensure the icon is rendered at 1:1 without resampling (crisper edges for template images). + button.imageScaling = .scaleNone + button.setAccessibilityIdentifier(identity.accessibilityIdentifier) + button.setAccessibilityTitle(self.statusItemAccessibilityTitle) + button.toolTip = self.statusItemAccessibilityTitle + } return item } @@ -279,11 +358,23 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastSwitcherShowsIcons = settings.switcherShowsIcons self.lastObservedUsageBarsShowUsed = settings.usageBarsShowUsed self.lastSwitcherUsageBarsShowUsed = settings.usageBarsShowUsed + let repairedStatusItemVisibilityKeys = MenuBarStatusItemDefaultsRepair + .repairHiddenVisibilityDefaultsIfNeeded(defaults: settings.userDefaults) self.statusBar = statusBar - self.statusItem = Self.makeStatusItem(statusBar: statusBar) + self.statusItem = Self.makeStatusItem( + statusBar: statusBar, + identity: .merged, + defaults: settings.userDefaults, + legacyDefaultItemIndex: Self.mergedLegacyDefaultItemIndex) self.lastKnownScreenCount = NSScreen.screens.count // Status items for individual providers are now created lazily in updateVisibility() super.init() + if !repairedStatusItemVisibilityKeys.isEmpty { + self.menuLogger.info( + "Repaired hidden macOS status-item visibility defaults", + metadata: ["keys": repairedStatusItemVisibilityKeys.joined(separator: ",")]) + } + self.lastMenuAdjunctReadinessSignature = self.menuAdjunctReadinessSignature() self.wireBindings() self.updateVisibility() self.updateIcons() @@ -355,7 +446,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin Task { @MainActor [weak self] in guard let self else { return } self.observeStoreChanges() - self.invalidateMenus() + self.invalidateMenus( + refreshOpenMenus: self.didMenuAdjunctReadinessChange(), + deferOpenParentMenuRebuild: true) } } } @@ -520,33 +613,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin } } - func invalidateMenus(refreshOpenMenus: Bool = false) { - #if DEBUG - guard !self.isReleasedForTesting else { return } - #endif - self.menuContentVersion &+= 1 - guard Self.menuRefreshEnabled else { return } - if !self.openMenus.isEmpty { - guard refreshOpenMenus else { return } - self.refreshOpenMenusAllowingParentRebuild() - Task { @MainActor [weak self] in - guard let self else { return } - // AppKit can ignore menu mutations while tracking; retry on the next run loop. - await Task.yield() - self.refreshOpenMenusAllowingParentRebuild() - } - return - } - self.refreshOpenMenusIfNeeded() - Task { @MainActor [weak self] in - guard let self else { return } - // AppKit can ignore menu mutations while tracking; retry on the next run loop. - await Task.yield() - guard self.openMenus.isEmpty else { return } - self.refreshOpenMenusIfNeeded() - } - } - private func shouldRefreshOpenMenusForProviderSwitcher() -> Bool { var shouldRefresh = false let revision = self.settings.configRevision @@ -574,6 +640,9 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.lastObservedUsageBarsShowUsed = usageBarsShowUsed shouldRefresh = true } + if self.menuLocalizationSignature() != self.lastMenuLocalizationSignature { + shouldRefresh = true + } return shouldRefresh } @@ -637,7 +706,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin if let existing = self.statusItems[provider] { return existing } - let item = Self.makeStatusItem(statusBar: self.statusBar) + let item = Self.makeStatusItem( + statusBar: self.statusBar, + identity: .provider(provider), + defaults: self.settings.userDefaults, + legacyDefaultItemIndex: self.legacyDefaultItemIndex(forNewProvider: provider)) self.statusItems[provider] = item return item } @@ -648,7 +721,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin #endif self.statusItem.menu = nil self.statusBar.removeStatusItem(self.statusItem) - self.statusItem = Self.makeStatusItem(statusBar: self.statusBar) + self.statusItem = Self.makeStatusItem( + statusBar: self.statusBar, + identity: .merged, + defaults: self.settings.userDefaults, + legacyDefaultItemIndex: Self.mergedLegacyDefaultItemIndex) for provider in Array(self.statusItems.keys) { self.removeProviderStatusItem(for: provider) } @@ -777,6 +854,11 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin self.menuVersions.removeValue(forKey: menuID) self.openMenus.removeValue(forKey: menuID) self.menuRefreshTasks.removeValue(forKey: menuID)?.cancel() + self.openMenuRebuildTasks.removeValue(forKey: menuID)?.cancel() + self.openMenuRebuildTokens.removeValue(forKey: menuID) + self.openMenuRebuildsClosingHostedSubviewMenus.remove(menuID) + self.parentMenuRebuildsDeferredDuringTracking.remove(menuID) + self.highlightedMenuItems.removeValue(forKey: menuID) } guard let item = self.statusItems.removeValue(forKey: provider) else { return } @@ -807,46 +889,6 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin return "\(prefix): \(base)" } - #if DEBUG - func releaseStatusItemsForTesting() { - guard !self.isReleasedForTesting else { return } - self.isReleasedForTesting = true - self.blinkTask?.cancel() - self.loginTask?.cancel() - self.screenChangeVisibilityTask?.cancel() - self.pendingScreenChangePreviousCount = nil - self.animationDriver?.stop() - self.animationDriver = nil - self.animationPhase = 0 - self.blinkForceUntil = nil - self.blinkStates.removeAll(keepingCapacity: false) - self.blinkAmounts.removeAll(keepingCapacity: false) - self.wiggleAmounts.removeAll(keepingCapacity: false) - self.tiltAmounts.removeAll(keepingCapacity: false) - - for task in self.menuRefreshTasks.values { - task.cancel() - } - self.menuRefreshTasks.removeAll(keepingCapacity: false) - self.openMenus.removeAll(keepingCapacity: false) - self.menuProviders.removeAll(keepingCapacity: false) - self.menuVersions.removeAll(keepingCapacity: false) - self.providerMenus.removeAll(keepingCapacity: false) - self.mergedMenu = nil - self.fallbackMenu = nil - - self.statusItem.menu = nil - self.statusBar.removeStatusItem(self.statusItem) - - for item in self.statusItems.values { - item.menu = nil - self.statusBar.removeStatusItem(item) - } - self.statusItems.removeAll(keepingCapacity: false) - self.lastAppliedProviderIconRenderSignatures.removeAll(keepingCapacity: false) - } - #endif - deinit { let animationDriver = self.animationDriver Task { @MainActor in @@ -859,3 +901,26 @@ final class StatusItemController: NSObject, NSMenuDelegate, StatusItemControllin NotificationCenter.default.removeObserver(self) } } + +extension StatusItemController { + private func legacyDefaultItemIndex(forNewProvider provider: UsageProvider) -> Int? { + let visibleProviders = self.settings.orderedProviders().filter { self.isVisible($0) } + guard let providerOffset = visibleProviders.firstIndex(of: provider) else { return nil } + return Self.mergedLegacyDefaultItemIndex + 1 + providerOffset + } + + func refreshExistingStatusItemsForVisibilityRecovery() { + #if DEBUG + guard !self.isReleasedForTesting else { return } + #endif + let visibleItems = ([self.statusItem] + Array(self.statusItems.values)).filter(\.isVisible) + for item in visibleItems { + item.isVisible = false + } + for item in visibleItems { + item.isVisible = true + } + self.updateVisibility() + self.updateIcons() + } +} diff --git a/Sources/CodexBar/StatusItemMenu.swift b/Sources/CodexBar/StatusItemMenu.swift index e16a3143..be8a7b0d 100644 --- a/Sources/CodexBar/StatusItemMenu.swift +++ b/Sources/CodexBar/StatusItemMenu.swift @@ -61,7 +61,7 @@ final class StatusItemMenu: NSMenu { } } - private nonisolated static func providerNavigationDirection( + nonisolated static func providerNavigationDirection( for event: NSEvent) -> StatusItemMenuProviderNavigationDirection? { guard event.type == .keyDown else { return nil } @@ -76,4 +76,18 @@ final class StatusItemMenu: NSMenu { return nil } } + + nonisolated static func providerSelectionIndex(for event: NSEvent) -> Int? { + guard event.type == .keyDown else { return nil } + let relevantModifiers = event.modifierFlags.intersection([.command, .option, .control, .shift]) + guard relevantModifiers == .command, + let characters = event.charactersIgnoringModifiers, + characters.count == 1, + let number = Int(characters), + (1...9).contains(number) + else { + return nil + } + return number - 1 + } } diff --git a/Sources/CodexBar/StorageBreakdownMenuView.swift b/Sources/CodexBar/StorageBreakdownMenuView.swift index dbdbfda7..276a5365 100644 --- a/Sources/CodexBar/StorageBreakdownMenuView.swift +++ b/Sources/CodexBar/StorageBreakdownMenuView.swift @@ -10,7 +10,7 @@ struct StorageMenuCardSectionView: View { var body: some View { VStack(alignment: .leading, spacing: 6) { - Text("Storage") + Text(L("Storage")) .font(.body) .fontWeight(.medium) Text(self.storageText) @@ -67,16 +67,16 @@ struct StorageBreakdownMenuView: View { private var content: some View { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 3) { - Text("Storage") + Text(L("Storage")) .font(.body) .fontWeight(.medium) - Text("Total: \(UsageFormatter.byteCountString(self.footprint.totalBytes))") + Text(String(format: L("Total: %@"), UsageFormatter.byteCountString(self.footprint.totalBytes))) .font(.caption) .foregroundStyle(.secondary) } if self.visibleComponents.isEmpty { - Text("No local data found") + Text(L("No local data found")) .font(.footnote) .foregroundStyle(.secondary) } else { @@ -88,7 +88,7 @@ struct StorageBreakdownMenuView: View { } if self.footprint.components.count > self.visibleComponents.count { - Text("\(self.footprint.components.count - self.visibleComponents.count) more items") + Text(String(format: L("%d more items"), self.footprint.components.count - self.visibleComponents.count)) .font(.caption) .foregroundStyle(.secondary) } @@ -96,7 +96,7 @@ struct StorageBreakdownMenuView: View { Divider() .padding(.vertical, 2) VStack(alignment: .leading, spacing: 8) { - Text("Cleanup ideas") + Text(L("Cleanup ideas")) .font(.body) .fontWeight(.medium) ForEach(self.cleanupRecommendations) { recommendation in @@ -105,7 +105,7 @@ struct StorageBreakdownMenuView: View { } } if !self.footprint.unreadablePaths.isEmpty { - Text("\(self.footprint.unreadablePaths.count) unreadable item(s) skipped") + Text(String(format: L("%d unreadable item(s) skipped"), self.footprint.unreadablePaths.count)) .font(.caption) .foregroundStyle(.secondary) } @@ -148,7 +148,7 @@ struct StorageBreakdownMenuView: View { private func recommendationRow(_ recommendation: ProviderStorageRecommendation) -> some View { VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline) { - Text(recommendation.title) + Text(L(recommendation.title)) .font(.caption) .fontWeight(.medium) .lineLimit(1) @@ -169,7 +169,7 @@ struct StorageBreakdownMenuView: View { Spacer() StoragePathCopyButton(path: recommendation.path) } - Text(recommendation.consequence) + Text(L(recommendation.consequence)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(3) @@ -210,8 +210,8 @@ struct StoragePathCopyButton: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .help(self.didCopy ? "Copied" : "Copy path") - .accessibilityLabel(self.didCopy ? "Copied" : "Copy path") + .help(self.didCopy ? L("Copied") : L("Copy path")) + .accessibilityLabel(self.didCopy ? L("Copied") : L("Copy path")) } static func copyToPasteboard(_ path: String) { diff --git a/Sources/CodexBar/UsageBreakdownChartMenuView.swift b/Sources/CodexBar/UsageBreakdownChartMenuView.swift index ad53befa..0b1ca524 100644 --- a/Sources/CodexBar/UsageBreakdownChartMenuView.swift +++ b/Sources/CodexBar/UsageBreakdownChartMenuView.swift @@ -31,24 +31,24 @@ struct UsageBreakdownChartMenuView: View { let model = Self.makeModel(from: self.breakdown) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { - Text("No usage breakdown data.") + Text(L("No usage breakdown data.")) .font(.footnote) .foregroundStyle(.secondary) - .accessibilityLabel("No usage breakdown data available.") + .accessibilityLabel(L("No usage breakdown data available.")) } else { Chart { ForEach(model.points) { point in BarMark( - x: .value("Day", point.date, unit: .day), - y: .value("Credits used", point.creditsUsed)) - .foregroundStyle(by: .value("Service", point.service)) + x: .value(L("Day"), point.date, unit: .day), + y: .value(L("Credits used"), point.creditsUsed)) + .foregroundStyle(by: .value(L("Service"), point.service)) } if let peak = model.peakPoint { let capStart = max(peak.creditsUsed - Self.capHeight(maxValue: model.maxCreditsUsed), 0) BarMark( - x: .value("Day", peak.date, unit: .day), - yStart: .value("Cap start", capStart), - yEnd: .value("Cap end", peak.creditsUsed)) + x: .value(L("Day"), peak.date, unit: .day), + yStart: .value(L("Cap start"), capStart), + yEnd: .value(L("Cap end"), peak.creditsUsed)) .foregroundStyle(Color(nsColor: .systemYellow)) } } @@ -65,11 +65,14 @@ struct UsageBreakdownChartMenuView: View { } .chartLegend(.hidden) .frame(height: 130) - .accessibilityLabel("Usage breakdown chart") + .accessibilityLabel(L("Usage breakdown chart")) .accessibilityValue( model.points.isEmpty - ? "No data" - : "\(model.points.count) days of usage data across \(model.services.count) services") + ? L("No data") + : String( + format: L("%d days of usage data across %d services"), + model.points.count, + model.services.count)) .chartOverlay { proxy in GeometryReader { geo in ZStack(alignment: .topLeading) { @@ -368,7 +371,7 @@ struct UsageBreakdownChartMenuView: View { let day = model.breakdownByDayKey[key], let date = Self.dateFromDayKey(key) else { - return ("Hover a bar for details", nil) + return (L("Hover a bar for details"), nil) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index fd245824..fb5af32d 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -17,9 +17,9 @@ enum UsagePaceText { static func weeklySummary(pace: UsagePace, now: Date = .init()) -> String { let detail = self.weeklyDetail(pace: pace, now: now) if let rightLabel = detail.rightLabel { - return "Pace: \(detail.leftLabel) · \(rightLabel)" + return L("Pace: %@ · %@", detail.leftLabel, rightLabel) } - return "Pace: \(detail.leftLabel)" + return L("Pace: %@", detail.leftLabel) } static func weeklyDetail(pace: UsagePace, now: Date = .init()) -> WeeklyDetail { @@ -34,31 +34,34 @@ enum UsagePaceText { let deltaValue = Int(abs(pace.deltaPercent).rounded()) switch pace.stage { case .onTrack: - return "On pace" + return L("On pace") case .slightlyAhead, .ahead, .farAhead: - return "\(deltaValue)% in deficit" + return L("%d%% in deficit", deltaValue) case .slightlyBehind, .behind, .farBehind: - return "\(deltaValue)% in reserve" + return L("%d%% in reserve", deltaValue) } } private static func detailRightLabel(for pace: UsagePace, context: DetailContext, now: Date) -> String? { let etaLabel: String? if pace.willLastToReset { - etaLabel = "Lasts until reset" + etaLabel = L("Lasts until reset") } else if let etaSeconds = pace.etaSeconds { let etaText = Self.durationText(seconds: etaSeconds, now: now) - let prefix = context == .session ? "Projected empty" : "Runs out" - etaLabel = etaText == "now" ? "\(prefix) now" : "\(prefix) in \(etaText)" + if context == .session { + etaLabel = etaText == "now" ? L("Projected empty now") : L("Projected empty in %@", etaText) + } else { + etaLabel = etaText == "now" ? L("Runs out now") : L("Runs out in %@", etaText) + } } else { etaLabel = nil } guard let runOutProbability = pace.runOutProbability else { return etaLabel } let roundedRisk = self.roundedRiskPercent(runOutProbability) - let riskLabel = "≈ \(roundedRisk)% run-out risk" + let riskLabel = L("≈ %d%% run-out risk", roundedRisk) if let etaLabel { - return "\(etaLabel) · \(riskLabel)" + return L("%@ · %@", etaLabel, riskLabel) } return riskLabel } @@ -78,7 +81,8 @@ enum UsagePaceText { } static func sessionPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? { - guard provider == .codex || provider == .claude else { return nil } + guard provider == .codex || provider == .claude || provider == .ollama else { return nil } + if provider == .ollama, window.windowMinutes == nil { return nil } guard window.remainingPercent > 0 else { return nil } guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300) else { return nil } guard pace.expectedUsedPercent >= 3 else { return nil } @@ -97,8 +101,8 @@ enum UsagePaceText { static func sessionSummary(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> String? { guard let detail = sessionDetail(provider: provider, window: window, now: now) else { return nil } if let rightLabel = detail.rightLabel { - return "Pace: \(detail.leftLabel) · \(rightLabel)" + return L("Pace: %@ · %@", detail.leftLabel, rightLabel) } - return "Pace: \(detail.leftLabel)" + return L("Pace: %@", detail.leftLabel) } } diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index fbb344e3..63bdc682 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -78,16 +78,16 @@ struct UsageProgressBar: View { } if !markerPercents.isEmpty { - let markerWidth = max(1 / scale, 2) - let markerColor: Color = self.isHighlighted ? .white : .primary.opacity(0.72) + let markerColor = Self.warningMarkerColor(isHighlighted: self.isHighlighted) for markerPercent in markerPercents { let x = size.width * markerPercent / 100 - let markerRect = CGRect( - x: x - markerWidth / 2, - y: 0, - width: markerWidth, - height: size.height) - context.fill(Path(markerRect), with: .color(markerColor)) + let markerRect = Self.warningMarkerRect(x: x, size: size, scale: scale) + let markerPath = Path { p in + p.addRoundedRect( + in: markerRect, + cornerSize: CGSize(width: markerRect.width / 2, height: markerRect.width / 2)) + } + context.fill(markerPath, with: .color(markerColor)) } } @@ -169,6 +169,25 @@ struct UsageProgressBar: View { return (punchedStripe, centerStripe) } + nonisolated static func warningMarkerRect(x: CGFloat, size: CGSize, scale rawScale: CGFloat) -> CGRect { + let scale = max(rawScale, 1) + let width = max(1 / scale, 1) + let height = min(size.height, max(1 / scale, size.height * 0.55)) + let align: (CGFloat) -> CGFloat = { value in + (value * scale).rounded() / scale + } + + return CGRect( + x: align(x - width / 2), + y: align((size.height - height) / 2), + width: width, + height: align(height)) + } + + nonisolated static func warningMarkerColor(isHighlighted: Bool) -> Color { + isHighlighted ? .white.opacity(0.72) : .primary.opacity(0.32) + } + private static func clampedPercent(_ value: Double?) -> Double { guard let value else { return 0 } return min(100, max(0, value)) diff --git a/Sources/CodexBar/UsageStore+ClaudeDebug.swift b/Sources/CodexBar/UsageStore+ClaudeDebug.swift index f836953f..4adcf72d 100644 --- a/Sources/CodexBar/UsageStore+ClaudeDebug.swift +++ b/Sources/CodexBar/UsageStore+ClaudeDebug.swift @@ -1,4 +1,6 @@ +import AppKit import CodexBarCore +import CodexBarSync import Foundation import SweetCookieKit @@ -172,3 +174,490 @@ extension UsageStore { } } } + +extension UsageStore { + func debugDumpClaude() async { + let fetcher = ClaudeUsageFetcher( + browserDetection: self.browserDetection, + keepCLISessionsAlive: self.settings.debugKeepCLISessionsAlive) + let output = await fetcher.debugRawProbe(model: "sonnet") + let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("codexbar-claude-probe.txt") + try? output.write(to: url, atomically: true, encoding: .utf8) + await MainActor.run { + let snippet = String(output.prefix(180)).replacingOccurrences(of: "\n", with: " ") + self.errors[.claude] = "[Claude] \(snippet) (saved: \(url.path))" + NSWorkspace.shared.open(url) + } + } + + func dumpLog(toFileFor provider: UsageProvider) async -> URL? { + let text = await self.debugLog(for: provider) + let filename = "codexbar-\(provider.rawValue)-probe.txt" + let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(filename) + do { + try text.write(to: url, atomically: true, encoding: .utf8) + _ = await MainActor.run { NSWorkspace.shared.open(url) } + return url + } catch { + await MainActor.run { + self.errors[provider] = "Failed to save log: \(error.localizedDescription)" + } + return nil + } + } + + func debugAugmentDump() async -> String { + await AugmentStatusProbe.latestDumps() + } + + // swiftlint:disable:next function_body_length + func debugLog(for provider: UsageProvider) async -> String { + if let cached = self.probeLogs[provider], !cached.isEmpty { + return cached + } + + let claudeWebExtrasEnabled = self.settings.claudeWebExtrasEnabled + let claudeUsageDataSource = self.settings.claudeUsageDataSource + let claudeCookieSource = self.settings.claudeCookieSource + let claudeCookieHeader = self.settings.claudeCookieHeader + let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude { + await self.makeClaudeDebugConfiguration( + fallbackUsageDataSource: claudeUsageDataSource, + fallbackWebExtrasEnabled: claudeWebExtrasEnabled, + fallbackCookieSource: claudeCookieSource, + fallbackCookieHeader: claudeCookieHeader) + } else { + nil + } + let cursorCookieSource = self.settings.cursorCookieSource + let cursorCookieHeader = self.settings.cursorCookieHeader + let ampCookieSource = self.settings.ampCookieSource + let ampCookieHeader = self.settings.ampCookieHeader + let ollamaCookieSource = self.settings.ollamaCookieSource + let ollamaCookieHeader = self.settings.ollamaCookieHeader + let processEnvironment = self.environmentBase + let openAIDebugContext = self.openAIAPIKeyDebugContext(processEnvironment: processEnvironment) + let azureOpenAIDebugContext = self.azureOpenAIAPIKeyDebugContext(processEnvironment: processEnvironment) + let openRouterDebugContext = self.openRouterAPIKeyDebugContext(processEnvironment: processEnvironment) + let elevenLabsDebugContext = self.elevenLabsAPIKeyDebugContext(processEnvironment: processEnvironment) + let deepSeekHasEnvToken = DeepSeekSettingsReader.apiKey(environment: processEnvironment) != nil + let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil + let deepSeekEnvironment = ProviderRegistry.makeEnvironment( + base: processEnvironment, + provider: .deepseek, + settings: self.settings, + tokenOverride: nil) + let codexFetcher = self.codexFetcher + let browserDetection = self.browserDetection + let claudeDebugExecutionContext = self.currentClaudeDebugExecutionContext() + let text = await Task.detached(priority: .utility) { () -> String in + let unimplementedDebugLogMessages: [UsageProvider: String] = [ + .gemini: "Gemini debug log not yet implemented", + .antigravity: "Antigravity debug log not yet implemented", + .opencode: "OpenCode debug log not yet implemented", + .alibaba: "Alibaba Coding Plan debug log not yet implemented", + .alibabatokenplan: "Alibaba Token Plan debug log not yet implemented", + .factory: "Droid debug log not yet implemented", + .copilot: "Copilot debug log not yet implemented", + .manus: "Manus debug log not yet implemented", + .vertexai: "Vertex AI debug log not yet implemented", + .kilo: "Kilo debug log not yet implemented", + .kiro: "Kiro debug log not yet implemented", + .kimi: "Kimi debug log not yet implemented", + .kimik2: "Kimi K2 debug log not yet implemented", + .jetbrains: "JetBrains AI debug log not yet implemented", + .mimo: "Xiaomi MiMo debug log not yet implemented", + .doubao: "Doubao debug log not yet implemented", + .venice: "Venice debug log not yet implemented", + .commandcode: "Command Code debug log not yet implemented", + .stepfun: "StepFun debug log not yet implemented", + .bedrock: "Bedrock debug log not yet implemented", + .grok: "Grok debug log not yet implemented", + .groq: "Groq debug log not yet implemented", + .t3chat: "T3 Chat debug log not yet implemented", + .llmproxy: "LLM Proxy debug log not yet implemented", + .deepgram: "Deepgram debug log not yet implemented", + ] + let buildText = { + switch provider { + case .codex: + return await codexFetcher.debugRawRateLimits() + case .openai: + return Self.apiKeyDebugLine(openAIDebugContext) + case .azureopenai: + return Self.apiKeyDebugLine(azureOpenAIDebugContext) + case .claude: + guard let claudeDebugConfiguration else { + return "Claude debug log configuration unavailable" + } + return await claudeDebugExecutionContext.apply { + await Self.debugClaudeLog( + browserDetection: browserDetection, + configuration: claudeDebugConfiguration) + } + case .zai: + let resolution = ProviderTokenResolver.zaiResolution() + let hasAny = resolution != nil + let source = resolution?.source.rawValue ?? "none" + return "Z_AI_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + case .synthetic: + let resolution = ProviderTokenResolver.syntheticResolution() + let hasAny = resolution != nil + let source = resolution?.source.rawValue ?? "none" + return "SYNTHETIC_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + case .cursor: + return await Self.debugCursorLog( + browserDetection: browserDetection, + cursorCookieSource: cursorCookieSource, + cursorCookieHeader: cursorCookieHeader) + case .minimax: + let tokenResolution = ProviderTokenResolver.minimaxTokenResolution() + let cookieResolution = ProviderTokenResolver.minimaxCookieResolution() + let tokenSource = tokenResolution?.source.rawValue ?? "none" + let cookieSource = cookieResolution?.source.rawValue ?? "none" + return "MINIMAX_API_KEY=\(tokenResolution == nil ? "missing" : "present") " + + "source=\(tokenSource) MINIMAX_COOKIE=\(cookieResolution == nil ? "missing" : "present") " + + "source=\(cookieSource)" + case .alibaba: + let resolution = ProviderTokenResolver.alibabaTokenResolution() + let hasAny = resolution != nil + let source = resolution?.source.rawValue ?? "none" + return "ALIBABA_CODING_PLAN_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + case .augment: + return await Self.debugAugmentLog() + case .amp: + return await Self.debugAmpLog( + browserDetection: browserDetection, + ampCookieSource: ampCookieSource, + ampCookieHeader: ampCookieHeader) + case .ollama: + return await Self.debugOllamaLog( + browserDetection: browserDetection, + ollamaCookieSource: ollamaCookieSource, + ollamaCookieHeader: ollamaCookieHeader) + case .openrouter: + return Self.apiKeyDebugLine(openRouterDebugContext) + case .elevenlabs: + return Self.apiKeyDebugLine(elevenLabsDebugContext) + case .warp: + let resolution = ProviderTokenResolver.warpResolution() + let hasAny = resolution != nil + let source = resolution?.source.rawValue ?? "none" + return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + case .deepseek: + return Self.apiKeyDebugLine( + label: "DEEPSEEK_API_KEY", + resolution: ProviderTokenResolver.deepseekResolution(environment: deepSeekEnvironment), + configToken: nil, + hasEnvToken: deepSeekHasEnvToken, + hasTokenAccount: deepSeekHasTokenAccount) + case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, + .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, + .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, + .grok, .groq, .t3chat, .llmproxy, .deepgram: + return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" + } + } + return await claudeDebugExecutionContext.apply { + await buildText() + } + }.value + self.probeLogs[provider] = text + return text + } + + private func makeClaudeDebugConfiguration( + fallbackUsageDataSource: ClaudeUsageDataSource, + fallbackWebExtrasEnabled: Bool, + fallbackCookieSource: ProviderCookieSource, + fallbackCookieHeader: String) async -> ClaudeDebugLogConfiguration + { + await MainActor.run { + let sourceMode = self.sourceMode(for: .claude) + let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: nil) + let environment = ProviderRegistry.makeEnvironment( + base: self.environmentBase, + provider: .claude, + settings: self.settings, + tokenOverride: nil) + let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings( + usageDataSource: fallbackUsageDataSource, + webExtrasEnabled: fallbackWebExtrasEnabled, + cookieSource: fallbackCookieSource, + manualCookieHeader: fallbackCookieHeader) + return ClaudeDebugLogConfiguration( + runtime: CodexBarCore.ProviderRuntime.app, + sourceMode: sourceMode, + environment: environment, + webExtrasEnabled: claudeSettings.webExtrasEnabled, + usageDataSource: claudeSettings.usageDataSource, + cookieSource: claudeSettings.cookieSource, + cookieHeader: claudeSettings.manualCookieHeader ?? "", + keepCLISessionsAlive: snapshot.debugKeepCLISessionsAlive) + } + } + + private struct ClaudeDebugExecutionContext { + let interaction: ProviderInteraction + let refreshPhase: ProviderRefreshPhase + #if DEBUG + let keychainServiceOverride: String? + let credentialsURLOverride: URL? + let testingOverrides: ClaudeOAuthCredentialsStore.TestingOverridesSnapshot + let keychainDeniedUntilStoreOverride: ClaudeOAuthKeychainAccessGate.DeniedUntilStore? + let keychainPromptModeOverride: ClaudeOAuthKeychainPromptMode? + let keychainReadStrategyOverride: ClaudeOAuthKeychainReadStrategy? + let cliPathOverride: String? + let statusFetchOverride: ClaudeStatusProbe.FetchOverride? + #endif + + func apply(_ operation: () async -> T) async -> T { + await ProviderInteractionContext.$current.withValue(self.interaction) { + await ProviderRefreshContext.$current.withValue(self.refreshPhase) { + #if DEBUG + return await KeychainCacheStore.withServiceOverrideForTesting(self.keychainServiceOverride) { + await ClaudeOAuthCredentialsStore + .withCredentialsURLOverrideForTesting(self.credentialsURLOverride) { + await ClaudeOAuthCredentialsStore + .withTestingOverridesSnapshotForTask(self.testingOverrides) { + await ClaudeOAuthKeychainAccessGate + .withDeniedUntilStoreOverrideForTesting(self + .keychainDeniedUntilStoreOverride) + { + await ClaudeOAuthKeychainPromptPreference + .withTaskOverrideForTesting(self.keychainPromptModeOverride) { + await ClaudeOAuthKeychainReadStrategyPreference + .withTaskOverrideForTesting(self + .keychainReadStrategyOverride) + { + await ClaudeCLIResolver + .withResolvedBinaryPathOverrideForTesting(self + .cliPathOverride) + { + await ClaudeStatusProbe + .withFetchOverrideForTesting(self + .statusFetchOverride) + { + await operation() + } + } + } + } + } + } + } + } + #else + return await operation() + #endif + } + } + } + } + + private func currentClaudeDebugExecutionContext() -> ClaudeDebugExecutionContext { + #if DEBUG + ClaudeDebugExecutionContext( + interaction: ProviderInteractionContext.current, + refreshPhase: ProviderRefreshContext.current, + keychainServiceOverride: KeychainCacheStore.currentServiceOverrideForTesting, + credentialsURLOverride: ClaudeOAuthCredentialsStore.currentCredentialsURLOverrideForTesting, + testingOverrides: ClaudeOAuthCredentialsStore.currentTestingOverridesSnapshotForTask, + keychainDeniedUntilStoreOverride: ClaudeOAuthKeychainAccessGate.currentDeniedUntilStoreOverrideForTesting, + keychainPromptModeOverride: ClaudeOAuthKeychainPromptPreference.currentTaskOverrideForTesting, + keychainReadStrategyOverride: ClaudeOAuthKeychainReadStrategyPreference.currentTaskOverrideForTesting, + cliPathOverride: ClaudeCLIResolver.currentResolvedBinaryPathOverrideForTesting, + statusFetchOverride: ClaudeStatusProbe.currentFetchOverrideForTesting) + #else + ClaudeDebugExecutionContext( + interaction: ProviderInteractionContext.current, + refreshPhase: ProviderRefreshContext.current) + #endif + } + + private struct APIKeyDebugContext { + let label: String + let resolution: ProviderTokenResolution? + let configToken: String? + let hasEnvToken: Bool + let hasTokenAccount: Bool + } + + private func openAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .openai) + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: .openai, + config: config) + return APIKeyDebugContext( + label: "OPENAI_API_KEY", + resolution: ProviderTokenResolver.openAIAPIResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: OpenAIAPISettingsReader.apiKey(environment: processEnvironment) != nil, + hasTokenAccount: false) + } + + private func azureOpenAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .azureopenai) + let environment = ProviderConfigEnvironment.applyProviderConfigOverrides( + base: processEnvironment, + provider: .azureopenai, + config: config) + return APIKeyDebugContext( + label: "AZURE_OPENAI_API_KEY", + resolution: ProviderTokenResolver.azureOpenAIResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: AzureOpenAISettingsReader.apiKey(environment: processEnvironment) != nil, + hasTokenAccount: false) + } + + private func openRouterAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .openrouter) + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: .openrouter, + config: config) + return APIKeyDebugContext( + label: "OPENROUTER_API_KEY", + resolution: ProviderTokenResolver.openRouterResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil, + hasTokenAccount: false) + } + + private func elevenLabsAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { + let config = self.settings.providerConfig(for: .elevenlabs) + let environment = ProviderConfigEnvironment.applyAPIKeyOverride( + base: processEnvironment, + provider: .elevenlabs, + config: config) + return APIKeyDebugContext( + label: "ELEVENLABS_API_KEY", + resolution: ProviderTokenResolver.elevenLabsResolution(environment: environment), + configToken: config?.sanitizedAPIKey, + hasEnvToken: ElevenLabsSettingsReader.apiKey(environment: processEnvironment) != nil, + hasTokenAccount: false) + } + + private nonisolated static func apiKeyDebugLine(_ context: APIKeyDebugContext) -> String { + self.apiKeyDebugLine( + label: context.label, + resolution: context.resolution, + configToken: context.configToken, + hasEnvToken: context.hasEnvToken, + hasTokenAccount: context.hasTokenAccount) + } + + private nonisolated static func apiKeyDebugLine( + label: String, + resolution: ProviderTokenResolution?, + configToken: String?, + hasEnvToken: Bool, + hasTokenAccount: Bool = false) -> String + { + let hasAny = resolution != nil + let hasConfigToken = !(configToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + let source: String = if resolution == nil { + "none" + } else if hasTokenAccount, hasEnvToken { + "settings-token-account (overrides env)" + } else if hasTokenAccount { + "settings-token-account" + } else if hasConfigToken, hasEnvToken { + "settings-config (overrides env)" + } else if hasConfigToken { + "settings-config" + } else { + resolution?.source.rawValue ?? "environment" + } + return "\(label)=\(hasAny ? "present" : "missing") source=\(source)" + } + + private static func debugCursorLog( + browserDetection: BrowserDetection, + cursorCookieSource: ProviderCookieSource, + cursorCookieHeader: String) async -> String + { + await runWithTimeout(seconds: 15) { + var lines: [String] = [] + + do { + let probe = CursorStatusProbe(browserDetection: browserDetection) + let snapshot: CursorStatusSnapshot = if cursorCookieSource == .manual, + let normalizedHeader = CookieHeaderNormalizer + .normalize(cursorCookieHeader) + { + try await probe.fetchWithManualCookies(normalizedHeader) + } else { + try await probe.fetch { msg in lines.append("[cursor-cookie] \(msg)") } + } + + lines.append("") + lines.append("Cursor Status Summary:") + lines.append("membershipType=\(snapshot.membershipType ?? "nil")") + lines.append("accountEmail=\(EmailRedaction.redact(snapshot.accountEmail))") + lines.append("planPercentUsed=\(snapshot.planPercentUsed)%") + lines.append("planUsedUSD=$\(snapshot.planUsedUSD)") + lines.append("planLimitUSD=$\(snapshot.planLimitUSD)") + lines.append("onDemandUsedUSD=$\(snapshot.onDemandUsedUSD)") + lines.append("onDemandLimitUSD=\(snapshot.onDemandLimitUSD.map { "$\($0)" } ?? "nil")") + if let teamUsed = snapshot.teamOnDemandUsedUSD { + lines.append("teamOnDemandUsedUSD=$\(teamUsed)") + } + if let teamLimit = snapshot.teamOnDemandLimitUSD { + lines.append("teamOnDemandLimitUSD=$\(teamLimit)") + } + lines.append("billingCycleEnd=\(snapshot.billingCycleEnd?.description ?? "nil")") + + if let rawJSON = snapshot.rawJSON { + lines.append("") + lines.append("Raw API Response:") + lines.append(rawJSON) + } + + return lines.joined(separator: "\n") + } catch { + lines.append("") + lines.append("Cursor probe failed: \(error.localizedDescription)") + return lines.joined(separator: "\n") + } + } + } + + private static func debugAugmentLog() async -> String { + await runWithTimeout(seconds: 15) { + let probe = AugmentStatusProbe() + return await probe.debugRawProbe() + } + } + + private static func debugAmpLog( + browserDetection: BrowserDetection, + ampCookieSource: ProviderCookieSource, + ampCookieHeader: String) async -> String + { + await runWithTimeout(seconds: 15) { + let fetcher = AmpUsageFetcher(browserDetection: browserDetection) + let manualHeader = ampCookieSource == .manual + ? CookieHeaderNormalizer.normalize(ampCookieHeader) + : nil + return await fetcher.debugRawProbe(cookieHeaderOverride: manualHeader) + } + } + + private static func debugOllamaLog( + browserDetection: BrowserDetection, + ollamaCookieSource: ProviderCookieSource, + ollamaCookieHeader: String) async -> String + { + await runWithTimeout(seconds: 15) { + let fetcher = OllamaUsageFetcher(browserDetection: browserDetection) + let manualHeader = ollamaCookieSource == .manual + ? CookieHeaderNormalizer.normalize(ollamaCookieHeader) + : nil + return await fetcher.debugRawProbe( + cookieHeaderOverride: manualHeader, + manualCookieMode: ollamaCookieSource == .manual) + } + } +} diff --git a/Sources/CodexBar/UsageStore+MemoryPressure.swift b/Sources/CodexBar/UsageStore+MemoryPressure.swift new file mode 100644 index 00000000..97e3d12b --- /dev/null +++ b/Sources/CodexBar/UsageStore+MemoryPressure.swift @@ -0,0 +1,19 @@ +import Foundation + +@MainActor +extension UsageStore { + func scheduleMemoryPressureRelief() { + guard self.memoryPressureReliefTask == nil else { return } + + self.memoryPressureReliefTask = Task.detached(priority: .utility) { [weak self] in + for delay in [Duration.seconds(2), .seconds(8), .seconds(20)] { + try? await Task.sleep(for: delay) + guard !Task.isCancelled else { return } + MemoryPressureRelief.releaseFreeMallocPages() + } + await MainActor.run { [weak self] in + self?.memoryPressureReliefTask = nil + } + } + } +} diff --git a/Sources/CodexBar/UsageStore+OpenAIWeb.swift b/Sources/CodexBar/UsageStore+OpenAIWeb.swift index c02e06c5..8effa6c3 100644 --- a/Sources/CodexBar/UsageStore+OpenAIWeb.swift +++ b/Sources/CodexBar/UsageStore+OpenAIWeb.swift @@ -15,6 +15,7 @@ struct OpenAIWebRefreshPolicyContext { let accessEnabled: Bool let batterySaverEnabled: Bool let force: Bool + let refreshPhase: ProviderRefreshPhase } // MARK: - OpenAI web lifecycle @@ -26,6 +27,16 @@ extension UsageStore { let expectedGuard: CodexAccountScopedRefreshGuard? let refreshTaskToken: UUID let allowCodexUsageBackfill: Bool + let force: Bool + } + + private struct OpenAIDashboardCookieImportRequest { + let normalizedTarget: String? + let allowAnyAccount: Bool + let cookieSource: ProviderCookieSource + let cacheScope: CookieHeaderCache.Scope? + let preferCachedCookieHeader: Bool? + let force: Bool } private static let openAIWebRefreshMultiplier: TimeInterval = 5 @@ -41,6 +52,19 @@ extension UsageStore { afterCookieImport ? self.openAIWebPostImportFetchTimeout : self.openAIWebRetryFetchTimeout } + nonisolated static func refreshPhase( + hasCompletedInitialRefresh: Bool) -> ProviderRefreshPhase + { + hasCompletedInitialRefresh ? .regular : .startup + } + + nonisolated static func openAIWebRefreshPhase( + providerRefreshPhase: ProviderRefreshPhase, + startupConnectivityRetryAttempt: Int?) -> ProviderRefreshPhase + { + startupConnectivityRetryAttempt == nil ? providerRefreshPhase : .startup + } + private func openAIWebRefreshIntervalSeconds() -> TimeInterval { let base = max(self.settings.refreshFrequency.seconds ?? 0, 120) return base * Self.openAIWebRefreshMultiplier @@ -53,13 +77,30 @@ extension UsageStore { else { return } let now = Date() let refreshInterval = self.openAIWebRefreshIntervalSeconds() - let lastUpdatedAt = self.openAIDashboard?.updatedAt ?? self.lastOpenAIDashboardSnapshot?.updatedAt - if let lastUpdatedAt, now.timeIntervalSince(lastUpdatedAt) < refreshInterval { return } + let dashboard = self.openAIDashboard ?? self.lastOpenAIDashboardSnapshot + let lastUpdatedAt = dashboard?.updatedAt + let needsMenuHistoryRefresh = dashboard?.dailyBreakdown.isEmpty == true && + dashboard?.usageBreakdown.isEmpty == true + if needsMenuHistoryRefresh, + Self.shouldSkipOpenAIWebEmptyHistoryRetry(.init( + force: false, + accountDidChange: self.openAIWebAccountDidChange, + lastError: self.lastOpenAIDashboardError, + lastSnapshotAt: lastUpdatedAt, + lastAttemptAt: self.lastOpenAIDashboardAttemptAt, + now: now, + refreshInterval: refreshInterval)) + { + return + } + if let lastUpdatedAt, now.timeIntervalSince(lastUpdatedAt) < refreshInterval, !needsMenuHistoryRefresh { + return + } let stamp = now.formatted(date: .abbreviated, time: .shortened) self.logOpenAIWeb("[\(stamp)] OpenAI web refresh request: \(reason)") let forceRefresh = Self.forceOpenAIWebRefreshForStaleRequest( - batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled) - self.openAIWebLogger.debug( + batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled) || needsMenuHistoryRefresh + self.openAIWebLogger.info( "OpenAI web stale refresh gate", metadata: [ "reason": reason, @@ -393,7 +434,8 @@ extension UsageStore { allowCurrentSnapshotFallback: allowCurrentSnapshotFallback, expectedGuard: expectedGuard, refreshTaskToken: taskToken, - allowCodexUsageBackfill: allowCodexUsageBackfill) + allowCodexUsageBackfill: allowCodexUsageBackfill, + force: force) let task = Task { [weak self] in guard let self else { return } await self.performOpenAIDashboardRefreshIfNeeded(context) @@ -409,7 +451,45 @@ extension UsageStore { } } + func scheduleOpenAIDashboardRefreshIfNeeded(expectedGuard: CodexAccountScopedRefreshGuard? = nil) { + self.syncOpenAIWebState() + let allowCurrentSnapshotFallback = expectedGuard?.source == .liveSystem && expectedGuard? + .identity == .unresolved + let targetEmail = self.currentCodexOpenAIWebTargetEmail( + allowCurrentSnapshotFallback: allowCurrentSnapshotFallback, + allowLastKnownLiveFallback: expectedGuard?.identity != .unresolved) + let refreshKey = self.openAIDashboardRefreshKey(targetEmail: targetEmail, expectedGuard: expectedGuard) + if let task = self.openAIDashboardBackgroundRefreshTask, + !task.isCancelled, + self.openAIDashboardBackgroundRefreshTaskKey == refreshKey + { + return + } + + if self.openAIDashboardBackgroundRefreshTaskKey != nil, + self.openAIDashboardBackgroundRefreshTaskKey != refreshKey + { + self.invalidateOpenAIDashboardRefreshTask() + } + self.openAIDashboardBackgroundRefreshTask?.cancel() + self.openAIDashboardBackgroundRefreshTaskKey = refreshKey + self.openAIDashboardBackgroundRefreshTask = Task(priority: .utility) { @MainActor [weak self] in + guard let self else { return } + defer { + if self.openAIDashboardBackgroundRefreshTaskKey == refreshKey { + self.openAIDashboardBackgroundRefreshTask = nil + self.openAIDashboardBackgroundRefreshTaskKey = nil + } + } + + await self.refreshOpenAIDashboardIfNeeded(force: false, expectedGuard: expectedGuard) + guard !Task.isCancelled else { return } + self.persistWidgetSnapshot(reason: "dashboard") + } + } + private func performOpenAIDashboardRefreshIfNeeded(_ context: OpenAIDashboardRefreshContext) async { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } self.openAIDashboardCookieImportStatus = nil var latestCookieImportStatus: String? if self.openAIWebDebugLines.isEmpty { @@ -440,6 +520,7 @@ extension UsageStore { let imported = await self.importOpenAIDashboardCookiesIfNeeded( targetEmail: targetEmail, force: true) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } didImportCookiesForRefresh = true latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() if await self.abortOpenAIDashboardRetryAfterImportFailure( @@ -461,20 +542,25 @@ extension UsageStore { var dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: log, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebDashboardFetchTimeout(didImportCookies: didImportCookiesForRefresh)) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } if self.dashboardEmailMismatch(expected: normalized, actual: dash.signedInEmail) { if let imported = await self.importOpenAIDashboardCookiesIfNeeded( targetEmail: context.targetEmail, force: true) { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } effectiveEmail = imported } latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: log, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } } await self.applyOpenAIDashboard( @@ -484,17 +570,20 @@ extension UsageStore { refreshTaskToken: context.refreshTaskToken, allowCodexUsageBackfill: context.allowCodexUsageBackfill) } catch let OpenAIDashboardFetcher.FetchError.noDashboardData(body) { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.retryOpenAIDashboardAfterNoData( body: body, context: context, latestCookieImportStatus: &latestCookieImportStatus, logger: log) } catch OpenAIDashboardFetcher.FetchError.loginRequired { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.retryOpenAIDashboardAfterLoginRequired( context: context, latestCookieImportStatus: &latestCookieImportStatus, logger: log) } catch { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } if Self.isOpenAIDashboardTimeout(error) { await self.retryOpenAIDashboardAfterTimeout( context: context, @@ -519,6 +608,17 @@ extension UsageStore { latestCookieImportStatus: inout String?, logger: @escaping (String) -> Void) async { + if !context.force { + OpenAIDashboardFetcher.evictAllCachedWebViews() + logger("OpenAI web refresh timed out; skipping immediate background retry.") + await self.applyOpenAIDashboardFailure( + message: "OpenAI web dashboard refresh timed out. CodexBar will retry after the refresh cooldown.", + expectedGuard: context.expectedGuard, + refreshTaskToken: context.refreshTaskToken, + routingTargetEmail: context.targetEmail) + return + } + let targetEmail = self.currentCodexOpenAIWebTargetEmail( allowCurrentSnapshotFallback: context.allowCurrentSnapshotFallback, allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) @@ -527,6 +627,7 @@ extension UsageStore { targetEmail: targetEmail, force: true, preferCachedCookieHeader: true) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() if await self.abortOpenAIDashboardRetryAfterImportFailure( importedEmail: imported, @@ -544,7 +645,9 @@ extension UsageStore { let dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: logger, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboard( dash, targetEmail: effectiveEmail, @@ -552,6 +655,7 @@ extension UsageStore { refreshTaskToken: context.refreshTaskToken, allowCodexUsageBackfill: context.allowCodexUsageBackfill) } catch { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } let message = self.preferredOpenAIDashboardFailureMessage( error: error, targetEmail: targetEmail, @@ -575,6 +679,7 @@ extension UsageStore { allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) var effectiveEmail = targetEmail let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() if await self.abortOpenAIDashboardRetryAfterImportFailure( importedEmail: imported, @@ -592,7 +697,9 @@ extension UsageStore { let dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: logger, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboard( dash, targetEmail: effectiveEmail, @@ -600,6 +707,7 @@ extension UsageStore { refreshTaskToken: context.refreshTaskToken, allowCodexUsageBackfill: context.allowCodexUsageBackfill) } catch let OpenAIDashboardFetcher.FetchError.noDashboardData(retryBody) { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } let finalBody = retryBody.isEmpty ? body : retryBody let message = self.openAIDashboardFriendlyError( body: finalBody, @@ -612,6 +720,7 @@ extension UsageStore { refreshTaskToken: context.refreshTaskToken, routingTargetEmail: targetEmail) } catch { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } let message = self.preferredOpenAIDashboardFailureMessage( error: error, targetEmail: targetEmail, @@ -634,6 +743,7 @@ extension UsageStore { allowLastKnownLiveFallback: context.expectedGuard?.identity != .unresolved) var effectiveEmail = targetEmail let imported = await self.importOpenAIDashboardCookiesIfNeeded(targetEmail: targetEmail, force: true) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } latestCookieImportStatus = self.currentOpenAIDashboardCookieImportStatus() if await self.abortOpenAIDashboardRetryAfterImportFailure( importedEmail: imported, @@ -651,7 +761,9 @@ extension UsageStore { let dash = try await self.loadLatestOpenAIDashboard( accountEmail: effectiveEmail, logger: logger, + allowNavigationTimeoutRetry: context.force, timeout: Self.openAIWebRetryDashboardFetchTimeout(afterCookieImport: true)) + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboard( dash, targetEmail: effectiveEmail, @@ -659,11 +771,13 @@ extension UsageStore { refreshTaskToken: context.refreshTaskToken, allowCodexUsageBackfill: context.allowCodexUsageBackfill) } catch OpenAIDashboardFetcher.FetchError.loginRequired { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } await self.applyOpenAIDashboardLoginRequiredFailure( expectedGuard: context.expectedGuard, refreshTaskToken: context.refreshTaskToken, routingTargetEmail: targetEmail) } catch { + guard self.shouldContinueOpenAIDashboardRefresh(token: context.refreshTaskToken) else { return } let message = self.preferredOpenAIDashboardFailureMessage( error: error, targetEmail: targetEmail, @@ -844,7 +958,14 @@ extension UsageStore { return self.openAIDashboardRefreshTaskToken == token } + private func shouldContinueOpenAIDashboardRefresh(token: UUID?) -> Bool { + !Task.isCancelled && self.shouldApplyOpenAIDashboardRefreshTask(token: token) + } + func invalidateOpenAIDashboardRefreshTask() { + self.openAIDashboardBackgroundRefreshTask?.cancel() + self.openAIDashboardBackgroundRefreshTask = nil + self.openAIDashboardBackgroundRefreshTaskKey = nil self.openAIDashboardRefreshTask?.cancel() self.openAIDashboardRefreshTask = nil self.openAIDashboardRefreshTaskKey = nil @@ -858,15 +979,17 @@ extension UsageStore { private func loadLatestOpenAIDashboard( accountEmail: String?, logger: @escaping (String) -> Void, + allowNavigationTimeoutRetry: Bool, timeout: TimeInterval) async throws -> OpenAIDashboardSnapshot { if let override = self._test_openAIDashboardLoaderOverride { - return try await override(accountEmail, logger, timeout) + return try await override(accountEmail, logger, allowNavigationTimeoutRetry, timeout) } return try await OpenAIDashboardFetcher().loadLatestDashboard( accountEmail: accountEmail, logger: logger, debugDumpHTML: timeout != Self.openAIWebPrimaryFetchTimeout, + allowNavigationTimeoutRetry: allowNavigationTimeoutRetry, timeout: timeout) } @@ -920,14 +1043,62 @@ extension UsageStore { return false } + private func openAIDashboardCookieImportResult( + request: OpenAIDashboardCookieImportRequest, + logger: @escaping (String) -> Void) async throws -> OpenAIDashboardBrowserCookieImporter.ImportResult + { + if let override = self._test_openAIDashboardCookieImportOverride { + return try await override( + request.normalizedTarget, + request.allowAnyAccount, + request.cookieSource, + request.cacheScope, + logger) + } + + let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: self.browserDetection) + switch request.cookieSource { + case .manual: + self.settings.ensureCodexCookieLoaded() + // Manual OpenAI cookies still come from one provider-level setting. Auto-imported cookies are + // isolated per managed account, but a manual header is an explicit override owned by settings, + // so switching managed accounts does not currently swap it underneath the user. + let manualHeader = self.settings.codexCookieHeader + guard CookieHeaderNormalizer.normalize(manualHeader) != nil else { + throw OpenAIDashboardBrowserCookieImporter.ImportError.manualCookieHeaderInvalid + } + return try await importer.importManualCookies( + cookieHeader: manualHeader, + intoAccountEmail: request.normalizedTarget, + allowAnyAccount: request.allowAnyAccount, + cacheScope: request.cacheScope, + logger: logger) + case .auto: + return try await importer.importBestCookies( + intoAccountEmail: request.normalizedTarget, + allowAnyAccount: request.allowAnyAccount, + preferCachedCookieHeader: request.preferCachedCookieHeader ?? !request.force, + cacheScope: request.cacheScope, + logger: logger) + case .off: + return OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Off", + cookieCount: 0, + signedInEmail: request.normalizedTarget, + matchesCodexEmail: true) + } + } + func importOpenAIDashboardCookiesIfNeeded( targetEmail: String?, force: Bool, preferCachedCookieHeader: Bool? = nil) async -> String? { + guard !Task.isCancelled else { return nil } if await self.openAIWebCookieImportShouldFailClosed() { return nil } + guard !Task.isCancelled else { return nil } let normalizedTarget = targetEmail?.trimmingCharacters(in: .whitespacesAndNewlines) let allowAnyAccount = normalizedTarget == nil || normalizedTarget?.isEmpty == true @@ -965,42 +1136,17 @@ extension UsageStore { self.logOpenAIWeb(message) } - let result: OpenAIDashboardBrowserCookieImporter.ImportResult - if let override = self._test_openAIDashboardCookieImportOverride { - result = try await override(normalizedTarget, allowAnyAccount, cookieSource, cacheScope, log) - } else { - let importer = OpenAIDashboardBrowserCookieImporter(browserDetection: self.browserDetection) - switch cookieSource { - case .manual: - self.settings.ensureCodexCookieLoaded() - // Manual OpenAI cookies still come from one provider-level setting. Auto-imported cookies are - // isolated per managed account, but a manual header is an explicit override owned by settings, - // so switching managed accounts does not currently swap it underneath the user. - let manualHeader = self.settings.codexCookieHeader - guard CookieHeaderNormalizer.normalize(manualHeader) != nil else { - throw OpenAIDashboardBrowserCookieImporter.ImportError.manualCookieHeaderInvalid - } - result = try await importer.importManualCookies( - cookieHeader: manualHeader, - intoAccountEmail: normalizedTarget, - allowAnyAccount: allowAnyAccount, - cacheScope: cacheScope, - logger: log) - case .auto: - result = try await importer.importBestCookies( - intoAccountEmail: normalizedTarget, - allowAnyAccount: allowAnyAccount, - preferCachedCookieHeader: preferCachedCookieHeader ?? !force, - cacheScope: cacheScope, - logger: log) - case .off: - result = OpenAIDashboardBrowserCookieImporter.ImportResult( - sourceLabel: "Off", - cookieCount: 0, - signedInEmail: normalizedTarget, - matchesCodexEmail: true) - } - } + let request = OpenAIDashboardCookieImportRequest( + normalizedTarget: normalizedTarget, + allowAnyAccount: allowAnyAccount, + cookieSource: cookieSource, + cacheScope: cacheScope, + preferCachedCookieHeader: preferCachedCookieHeader, + force: force) + let result = try await self.openAIDashboardCookieImportResult( + request: request, + logger: log) + guard !Task.isCancelled else { return nil } let effectiveEmail = result.signedInEmail? .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty == false @@ -1036,6 +1182,7 @@ extension UsageStore { } return effectiveEmail } catch let err as OpenAIDashboardBrowserCookieImporter.ImportError { + guard !Task.isCancelled else { return nil } switch err { case let .noMatchingAccount(found): let foundText: String = if found.isEmpty { @@ -1073,6 +1220,7 @@ extension UsageStore { } } } catch { + guard !Task.isCancelled else { return nil } self.logOpenAIWeb("[\(stamp)] import failed: \(error.localizedDescription)") await MainActor.run { self.openAIDashboardCookieImportStatus = @@ -1190,6 +1338,7 @@ extension UsageStore { extension UsageStore { nonisolated static func shouldRunOpenAIWebRefresh(_ context: OpenAIWebRefreshPolicyContext) -> Bool { guard context.accessEnabled else { return false } + guard context.force || context.refreshPhase != .startup else { return false } return context.force || !context.batterySaverEnabled } @@ -1213,6 +1362,15 @@ extension UsageStore { return false } + nonisolated static func shouldSkipOpenAIWebEmptyHistoryRetry(_ context: OpenAIWebRefreshGateContext) -> Bool { + if context.force || context.accountDidChange { return false } + guard let lastAttemptAt = context.lastAttemptAt, + context.now.timeIntervalSince(lastAttemptAt) < context.refreshInterval + else { return false } + guard let lastSnapshotAt = context.lastSnapshotAt else { return true } + return lastAttemptAt >= lastSnapshotAt + } + func syncOpenAIWebState() { guard self.isEnabled(.codex), self.settings.openAIWebAccessEnabled, diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 82b81708..2f87fbc5 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -115,6 +115,7 @@ extension UsageStore { providerBuckets.setHistories(updatedHistories, for: accountKey) self.planUtilizationHistory[provider] = providerBuckets + self.planUtilizationHistoryRevision &+= 1 snapshotToPersist = self.planUtilizationHistory } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 190d4d5e..22c5b19f 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -26,30 +26,7 @@ extension UsageStore { let codexExpectedGuard = provider == .codex ? self.currentCodexAccountScopedRefreshGuard() : nil if !spec.isEnabled(), !allowDisabled { - self.refreshingProviders.remove(provider) - await MainActor.run { - self.snapshots.removeValue(forKey: provider) - self.lastKnownResetSnapshots.removeValue(forKey: provider) - self.errors[provider] = nil - self.lastSourceLabels.removeValue(forKey: provider) - self.lastFetchAttempts.removeValue(forKey: provider) - self.accountSnapshots.removeValue(forKey: provider) - if provider == .codex { - self.codexAccountSnapshots = [] - } - if provider == .kilo { - self.kiloScopeSnapshots = [] - } - self.tokenSnapshots.removeValue(forKey: provider) - self.tokenErrors[provider] = nil - self.failureGates[provider]?.reset() - self.tokenFailureGates[provider]?.reset() - self.statuses.removeValue(forKey: provider) - self.lastKnownSessionRemaining.removeValue(forKey: provider) - self.lastKnownSessionWindowSource.removeValue(forKey: provider) - self.quotaWarningState = self.quotaWarningState.filter { $0.key.provider != provider } - self.lastTokenFetchAt.removeValue(forKey: provider) - } + await self.clearDisabledProviderRefreshState(provider) return } @@ -133,6 +110,14 @@ extension UsageStore { self.handleSessionQuotaTransition(provider: provider, snapshot: backfilled) self.lastKnownResetSnapshots[provider] = backfilled self.snapshots[provider] = backfilled + if let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: backfilled, provider: provider) { + self.tokenSnapshots[provider] = tokenSnapshot + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.recordSuccess() + } else if Self.tokenCostRequiresProviderSnapshot(provider) { + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + } self.lastSourceLabels[provider] = result.sourceLabel self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() @@ -163,6 +148,7 @@ extension UsageStore { { return } + self.recordStartupConnectivityRetryableFailure(error) if claudeCredentialsChanged { await self.clearClaudeCredentialDerivedStateForCredentialSwap() } @@ -173,6 +159,33 @@ extension UsageStore { } } + private func clearDisabledProviderRefreshState(_ provider: UsageProvider) async { + self.refreshingProviders.remove(provider) + await MainActor.run { + self.snapshots.removeValue(forKey: provider) + self.lastKnownResetSnapshots.removeValue(forKey: provider) + self.errors[provider] = nil + self.lastSourceLabels.removeValue(forKey: provider) + self.lastFetchAttempts.removeValue(forKey: provider) + self.accountSnapshots.removeValue(forKey: provider) + if provider == .codex { + self.codexAccountSnapshots = [] + } + if provider == .kilo { + self.kiloScopeSnapshots = [] + } + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + self.failureGates[provider]?.reset() + self.tokenFailureGates[provider]?.reset() + self.statuses.removeValue(forKey: provider) + self.lastKnownSessionRemaining.removeValue(forKey: provider) + self.lastKnownSessionWindowSource.removeValue(forKey: provider) + self.quotaWarningState = self.quotaWarningState.filter { $0.key.provider != provider } + self.lastTokenFetchAt.removeValue(forKey: provider) + } + } + private struct ClaudeRefreshAuthState { let fingerprintToken: String let credentialsFileChanged: Bool @@ -287,6 +300,16 @@ extension UsageStore { let shouldSurface = self.failureGates[provider]? .shouldSurfaceError(onFailureWithPriorData: hadPriorData) ?? true + let preservesClaudeWebSessionFailure = + provider == .claude && + hadPriorData && + Self.isClaudeWebSessionRefreshFailure(error) + if preservesClaudeWebSessionFailure, + !shouldSurface + { + self.errors[provider] = nil + return + } if provider == .claude, preservesPriorData, Self.isClaudeUsageProbeTimeout(error) @@ -300,7 +323,7 @@ extension UsageStore { } if shouldSurface { self.errors[provider] = error.localizedDescription - if !preservesPriorData { + if !preservesPriorData, !preservesClaudeWebSessionFailure { self.snapshots.removeValue(forKey: provider) } } else { @@ -347,11 +370,50 @@ extension UsageStore { } } + static func startupConnectivityRetryDelay(forAttempt attempt: Int) -> TimeInterval? { + let delays: [TimeInterval] = [15, 45, 120, 300] + guard attempt >= 1, attempt <= delays.count else { return nil } + return delays[attempt - 1] + } + + static func isStartupConnectivityRetryableError(_ error: Error) -> Bool { + if error is CancellationError { return false } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorTimedOut, + NSURLErrorNetworkConnectionLost, + NSURLErrorNotConnectedToInternet, + NSURLErrorCannotFindHost, + NSURLErrorCannotConnectToHost, + NSURLErrorDNSLookupFailed: + return true + default: + return false + } + } + + let message = error.localizedDescription.lowercased() + return message.contains("timed out") || + message.contains("timeout") || + message.contains("network connection was lost") || + message.contains("not connected to the internet") || + message.contains("cannot find host") || + message.contains("cannot connect to host") || + message.contains("dns lookup") + } + private static func isClaudeUsageProbeTimeout(_ error: Error) -> Bool { if case ClaudeStatusProbeError.timedOut = error { return true } return error.localizedDescription == ClaudeStatusProbeError.timedOut.localizedDescription } + private static func isClaudeWebSessionRefreshFailure(_ error: Error) -> Bool { + if case ClaudeWebAPIFetcher.FetchError.unauthorized = error { return true } + return error.localizedDescription == ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription + } + nonisolated static func isPermissionPromptWaiting(_ error: Error) -> Bool { let message = error.localizedDescription.lowercased() return (message.contains("prompt") && message.contains("waiting")) || @@ -370,7 +432,7 @@ extension UsageStore { let providerName = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName AppNotifications.shared.post( idPrefix: "permission-prompt-\(provider.rawValue)", - title: "\(providerName) is waiting for permission", + title: L("%@ is waiting for permission", providerName), body: error.localizedDescription, soundEnabled: false) } diff --git a/Sources/CodexBar/UsageStore+StartupConnectivityRetry.swift b/Sources/CodexBar/UsageStore+StartupConnectivityRetry.swift new file mode 100644 index 00000000..aceceb48 --- /dev/null +++ b/Sources/CodexBar/UsageStore+StartupConnectivityRetry.swift @@ -0,0 +1,83 @@ +import Foundation + +extension UsageStore { + enum StartupBehavior { + case automatic + case full + case testing + + var automaticallyStartsBackgroundWork: Bool { + switch self { + case .automatic, .full: + true + case .testing: + false + } + } + + func resolved(isRunningTests: Bool) -> StartupBehavior { + switch self { + case .automatic: + isRunningTests ? .testing : .full + case .full, .testing: + self + } + } + } + + func recordStartupConnectivityRetryableFailure(_ error: Error) { + guard self.startupConnectivityRetryRefreshActive else { return } + guard Self.isStartupConnectivityRetryableError(error) else { return } + self.startupConnectivityRetryNeeded = true + } + + func completeStartupConnectivityRetryPass(currentAttempt: Int) { + guard self.startupConnectivityRetryNeeded else { + self.cancelStartupConnectivityRetry() + return + } + + let nextAttempt = currentAttempt + 1 + guard let delay = Self.startupConnectivityRetryDelay(forAttempt: nextAttempt) else { + self.cancelStartupConnectivityRetry() + return + } + + self.scheduleStartupConnectivityRetry(attempt: nextAttempt, delay: delay) + } + + private func scheduleStartupConnectivityRetry(attempt: Int, delay: TimeInterval) { + guard self.startupBehavior.automaticallyStartsBackgroundWork || + self._test_startupConnectivityRetryScheduled != nil || + self._test_startupConnectivityRetrySleepOverride != nil + else { + return + } + + self.startupConnectivityRetryTask?.cancel() + self._test_startupConnectivityRetryScheduled?(attempt, delay) + self.startupConnectivityRetryTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await self.sleepForStartupConnectivityRetry(delay) + guard !Task.isCancelled else { return } + await self.runRefresh(startupConnectivityRetryAttempt: attempt) + } catch { + return + } + } + } + + private func cancelStartupConnectivityRetry() { + self.startupConnectivityRetryTask?.cancel() + self.startupConnectivityRetryTask = nil + } + + private func sleepForStartupConnectivityRetry(_ delay: TimeInterval) async throws { + if let override = self._test_startupConnectivityRetrySleepOverride { + try await override(delay) + return + } + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } +} diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 63ba475c..39bf1344 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -426,6 +426,13 @@ extension UsageStore { token: token) } }, + providerManualTokenUpdater: { [weak settings = self.settings] provider, token in + await MainActor.run { + if provider == .stepfun { + settings?.stepfunToken = token + } + } + }, costUsageHistoryDays: self.settings.costUsageHistoryDays) } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index d9317b0d..5e1550ce 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -14,6 +14,36 @@ extension UsageStore { self.lastTokenFetchAt[provider] } + func hydrateCachedTokenSnapshots(now: Date = Date()) { + guard self.settings.costUsageEnabled else { return } + guard self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata).contains(.codex) else { + return + } + + let scope = self.tokenCostScope(for: .codex) + let historyDays = self.settings.costUsageHistoryDays + Task { @MainActor [weak self] in + guard let self else { return } + guard self.tokenSnapshots[.codex] == nil else { return } + guard let snapshot = await self.costUsageFetcher.loadCachedCodexTokenSnapshot( + now: now, + codexHomePath: scope.codexHomePath, + historyDays: historyDays) + else { + return + } + guard self.settings.costUsageEnabled, + self.isEnabled(.codex), + self.tokenCostScope(for: .codex).signature == scope.signature, + self.tokenSnapshots[.codex] == nil + else { + return + } + self.tokenSnapshots[.codex] = snapshot + self.tokenErrors[.codex] = nil + } + } + func isTokenRefreshInFlight(for provider: UsageProvider) -> Bool { self.tokenRefreshInFlight.contains(provider) } @@ -30,6 +60,30 @@ extension UsageStore { return (homePath, "codex:managed:\(homePath)") } + func tokenSnapshot( + fromProviderSnapshot snapshot: UsageSnapshot?, + provider: UsageProvider) + -> CostUsageTokenSnapshot? + { + switch provider { + case .openai: + snapshot?.openAIAPIUsage?.toCostUsageTokenSnapshot() + case .mistral: + snapshot?.mistralUsage?.toCostUsageTokenSnapshot(historyDays: self.settings.costUsageHistoryDays) + default: + nil + } + } + + nonisolated static func tokenCostRequiresProviderSnapshot(_ provider: UsageProvider) -> Bool { + switch provider { + case .mistral, .openai: + true + default: + false + } + } + nonisolated static func costUsageCacheDirectory( fileManager: FileManager = .default) -> URL { diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index 73a8a07e..e8befc32 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -36,7 +36,8 @@ extension UsageStore { private func makeWidgetEntry(for provider: UsageProvider) -> WidgetSnapshot.ProviderEntry? { guard let snapshot = self.snapshots[provider] else { return nil } - let tokenSnapshot = self.tokenSnapshots[provider] + let tokenSnapshot = self.tokenSnapshot(fromProviderSnapshot: snapshot, provider: provider) ?? self + .tokenSnapshots[provider] let dailyUsage = tokenSnapshot?.daily.map { entry in WidgetSnapshot.DailyUsagePoint( dayKey: entry.date, @@ -44,7 +45,7 @@ extension UsageStore { costUSD: entry.costUSD) } ?? [] - let tokenUsage = Self.widgetTokenUsageSummary(from: tokenSnapshot) + let tokenUsage = Self.widgetTokenUsageSummary(from: tokenSnapshot, provider: provider) let usageRows = self.widgetUsageRows(provider: provider, snapshot: snapshot) let creditsRemaining: Double? @@ -76,16 +77,22 @@ extension UsageStore { } private nonisolated static func widgetTokenUsageSummary( - from snapshot: CostUsageTokenSnapshot?) -> WidgetSnapshot.TokenUsageSummary? + from snapshot: CostUsageTokenSnapshot?, + provider: UsageProvider) -> WidgetSnapshot.TokenUsageSummary? { guard let snapshot else { return nil } let fallbackTokens = snapshot.daily.compactMap(\.totalTokens).reduce(0, +) let monthTokensValue = snapshot.last30DaysTokens ?? (fallbackTokens > 0 ? fallbackTokens : nil) + let sessionLabel = provider == .bedrock || provider == .mistral ? "Latest billing day" : "Today" + let monthLabel = snapshot.historyLabel ?? (snapshot.historyDays == 1 ? "Today" : "\(snapshot.historyDays)d") return WidgetSnapshot.TokenUsageSummary( sessionCostUSD: snapshot.sessionCostUSD, sessionTokens: snapshot.sessionTokens, last30DaysCostUSD: snapshot.last30DaysCostUSD, - last30DaysTokens: monthTokensValue) + last30DaysTokens: monthTokensValue, + currencyCode: snapshot.currencyCode, + sessionLabel: sessionLabel, + last30DaysLabel: monthLabel) } private func widgetUsageRows( @@ -113,16 +120,31 @@ extension UsageStore { } } - let rows: [WidgetSnapshot.WidgetUsageRowSnapshot] = [ + let primaryTitle: String = { + if provider == .grok, + let dyn = GrokProviderDescriptor.primaryLabel(window: snapshot.primary) + { + return dyn + } + return metadata?.sessionLabel ?? "Session" + }() + + var rows: [WidgetSnapshot.WidgetUsageRowSnapshot] = [ WidgetSnapshot.WidgetUsageRowSnapshot( id: "primary", - title: metadata?.sessionLabel ?? "Session", + title: primaryTitle, percentLeft: snapshot.primary?.remainingPercent), WidgetSnapshot.WidgetUsageRowSnapshot( id: "secondary", title: metadata?.weeklyLabel ?? "Weekly", percentLeft: snapshot.secondary?.remainingPercent), ] + if metadata?.supportsOpus == true { + rows.append(WidgetSnapshot.WidgetUsageRowSnapshot( + id: "tertiary", + title: metadata?.opusLabel ?? "Opus", + percentLeft: snapshot.tertiary?.remainingPercent)) + } return rows.filter { $0.percentLeft != nil } } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index bbc11db4..d5672d5c 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1,4 +1,3 @@ -import AppKit import CodexBarCore import CodexBarSync import Foundation @@ -25,6 +24,7 @@ extension UsageStore { _ = self.openAIDashboard _ = self.lastOpenAIDashboardError _ = self.openAIDashboardRequiresLogin + _ = self.openAIDashboardAttachmentRevision _ = self.versions _ = self.isRefreshing _ = self.refreshingProviders @@ -32,6 +32,7 @@ extension UsageStore { _ = self.statuses _ = self.probeLogs _ = self.historicalPaceRevision + _ = self.planUtilizationHistoryRevision _ = self.providerStorageFootprints return 0 } @@ -116,30 +117,6 @@ final class UsageStore { case dashboardWeb } - enum StartupBehavior { - case automatic - case full - case testing - - var automaticallyStartsBackgroundWork: Bool { - switch self { - case .automatic, .full: - true - case .testing: - false - } - } - - func resolved(isRunningTests: Bool) -> StartupBehavior { - switch self { - case .automatic: - isRunningTests ? .testing : .full - case .full, .testing: - self - } - } - } - var snapshots: [UsageProvider: UsageSnapshot] = [:] var errors: [UsageProvider: String] = [:] var lastSourceLabels: [UsageProvider: String] = [:] @@ -165,12 +142,20 @@ final class UsageStore { var statuses: [UsageProvider: ProviderStatus] = [:] var probeLogs: [UsageProvider: String] = [:] var historicalPaceRevision: Int = 0 + var planUtilizationHistoryRevision: Int = 0 var providerStorageFootprints: [UsageProvider: ProviderStorageFootprint] = [:] @ObservationIgnored var lastCreditsSnapshot: CreditsSnapshot? @ObservationIgnored var lastCreditsSnapshotAccountKey: String? @ObservationIgnored var lastCreditsSource: CodexCreditsSource = .none @ObservationIgnored var creditsFailureStreak: Int = 0 - @ObservationIgnored var openAIDashboardAttachmentAuthorized: Bool = false + @ObservationIgnored var openAIDashboardAttachmentAuthorized: Bool = false { + didSet { + guard self.openAIDashboardAttachmentAuthorized != oldValue else { return } + self.openAIDashboardAttachmentRevision &+= 1 + } + } + + var openAIDashboardAttachmentRevision = 0 @ObservationIgnored var lastOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? @ObservationIgnored var lastOpenAIDashboardAttachmentAuthorized: Bool = false @ObservationIgnored var lastOpenAIDashboardTargetEmail: String? @@ -180,6 +165,10 @@ final class UsageStore { @ObservationIgnored var lastCodexAccountScopedRefreshGuard: CodexAccountScopedRefreshGuard? @ObservationIgnored var lastKnownLiveSystemCodexEmail: String? @ObservationIgnored var openAIWebAccountDidChange: Bool = false + @ObservationIgnored var creditsRefreshTask: Task? + @ObservationIgnored var creditsRefreshTaskKey: String? + @ObservationIgnored var openAIDashboardBackgroundRefreshTask: Task? + @ObservationIgnored var openAIDashboardBackgroundRefreshTaskKey: String? @ObservationIgnored var openAIDashboardRefreshTask: Task? @ObservationIgnored var openAIDashboardRefreshTaskKey: String? @ObservationIgnored var openAIDashboardRefreshTaskToken: UUID? @@ -192,16 +181,22 @@ final class UsageStore { @ObservationIgnored var _test_openAIDashboardLoaderOverride: (@MainActor ( String?, @escaping (String) -> Void, + Bool, TimeInterval) async throws -> OpenAIDashboardSnapshot)? @ObservationIgnored var _test_codexCreditsLoaderOverride: (@MainActor () async throws -> CreditsSnapshot)? @ObservationIgnored var _test_widgetSnapshotSaveOverride: (@MainActor (WidgetSnapshot) async -> Void)? @ObservationIgnored var _test_providerRefreshOverride: (@MainActor (UsageProvider) async -> Void)? @ObservationIgnored var _test_tokenUsageRefreshOverride: (@MainActor (UsageProvider, Bool) async -> Void)? + @ObservationIgnored var _test_providerStatusFetchOverride: (@MainActor ( + UsageProvider) async throws -> ProviderStatus)? + @ObservationIgnored var _test_startupConnectivityRetryScheduled: (@MainActor (Int, TimeInterval) -> Void)? + @ObservationIgnored var _test_startupConnectivityRetrySleepOverride: (@MainActor ( + TimeInterval) async throws -> Void)? @ObservationIgnored var widgetSnapshotPersistTask: Task? @ObservationIgnored let codexFetcher: UsageFetcher @ObservationIgnored let claudeFetcher: any ClaudeUsageFetching - @ObservationIgnored private let costUsageFetcher: CostUsageFetcher + @ObservationIgnored let costUsageFetcher: CostUsageFetcher @ObservationIgnored let browserDetection: BrowserDetection @ObservationIgnored private let registry: ProviderRegistry @ObservationIgnored let settings: SettingsStore @@ -223,6 +218,10 @@ final class UsageStore { @ObservationIgnored private var timerTask: Task? @ObservationIgnored private var tokenTimerTask: Task? @ObservationIgnored private var tokenRefreshSequenceTask: Task? + @ObservationIgnored var memoryPressureReliefTask: Task? + @ObservationIgnored var startupConnectivityRetryTask: Task? + @ObservationIgnored var startupConnectivityRetryNeeded = false + @ObservationIgnored var startupConnectivityRetryRefreshActive = false @ObservationIgnored var storageRefreshTask: Task? @ObservationIgnored var storageRefreshGeneration: UInt64 = 0 @ObservationIgnored var storageRefreshInFlightSignature: String? @@ -249,7 +248,7 @@ final class UsageStore { @ObservationIgnored private let providerAvailabilityCacheTTL: TimeInterval = 1 @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 - @ObservationIgnored private let startupBehavior: StartupBehavior + @ObservationIgnored let startupBehavior: StartupBehavior @ObservationIgnored let planUtilizationPersistenceCoordinator: PlanUtilizationHistoryPersistenceCoordinator init( @@ -316,6 +315,7 @@ final class UsageStore { effectivePATH: PathBuilder.effectivePATH(purposes: [.rpc, .tty, .nodeTooling]), loginShellPATH: LoginShellPathCache.shared.current?.joined(separator: ":")) guard self.startupBehavior.automaticallyStartsBackgroundWork else { return } + self.hydrateCachedTokenSnapshots() self.detectVersions() self.updateProviderRuntimes() Task { @MainActor [weak self] in @@ -529,9 +529,23 @@ final class UsageStore { } func refresh(forceTokenUsage: Bool = false) async { + await self.runRefresh(forceTokenUsage: forceTokenUsage, startupConnectivityRetryAttempt: nil) + } + + func runRefresh( + forceTokenUsage: Bool = false, + startupConnectivityRetryAttempt: Int?) + async + { guard !self.isRefreshing else { return } self.prepareRefreshState() - let refreshPhase: ProviderRefreshPhase = self.hasCompletedInitialRefresh ? .regular : .startup + let refreshPhase = Self.refreshPhase(hasCompletedInitialRefresh: self.hasCompletedInitialRefresh) + let openAIWebRefreshPhase = Self.openAIWebRefreshPhase( + providerRefreshPhase: refreshPhase, + startupConnectivityRetryAttempt: startupConnectivityRetryAttempt) + let allowsStartupConnectivityRetry = refreshPhase == .startup || startupConnectivityRetryAttempt != nil + self.startupConnectivityRetryRefreshActive = allowsStartupConnectivityRetry + self.startupConnectivityRetryNeeded = false let displayEnabledProviders = self.enabledProvidersForDisplay() let enabledProviderSet = Set(displayEnabledProviders) let refreshProviders = self.enabledProvidersForBackgroundWork() @@ -543,6 +557,7 @@ final class UsageStore { defer { self.isRefreshing = false self.hasCompletedInitialRefresh = true + self.startupConnectivityRetryRefreshActive = false } self.clearDisabledProviderState(enabledProviders: enabledProviderSet) @@ -558,7 +573,13 @@ final class UsageStore { group.addTask { await self.refreshStatus(provider) } } } - group.addTask { await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } + if forceTokenUsage { + group.addTask { await self.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt) } + } + } + + if !forceTokenUsage { + self.scheduleCreditsRefreshIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } if forceTokenUsage { @@ -576,7 +597,8 @@ final class UsageStore { self.settings.openAIWebAccessEnabled && self.settings.codexCookieSource.isEnabled, batterySaverEnabled: self.settings.openAIWebBatterySaverEnabled, - force: forceTokenUsage) + force: forceTokenUsage, + refreshPhase: openAIWebRefreshPhase) let shouldRefreshOpenAIWeb = Self.shouldRunOpenAIWebRefresh(refreshPolicy) self.openAIWebLogger.debug( "OpenAI web refresh gate", @@ -586,22 +608,33 @@ final class UsageStore { "batterySaverEnabled": refreshPolicy.batterySaverEnabled ? "1" : "0", "force": refreshPolicy.force ? "1" : "0", "interaction": ProviderInteractionContext.current == .userInitiated ? "user" : "background", - "phase": refreshPhase == .startup ? "startup" : "regular", + "phase": openAIWebRefreshPhase == .startup ? "startup" : "regular", ]) if shouldRefreshOpenAIWeb { let codexDashboardGuard = self.currentCodexOpenAIWebRefreshGuard() - await self.refreshOpenAIDashboardIfNeeded( - force: forceTokenUsage, - expectedGuard: codexDashboardGuard) + if forceTokenUsage { + await self.refreshOpenAIDashboardIfNeeded( + force: true, + expectedGuard: codexDashboardGuard) + } else { + self.scheduleOpenAIDashboardRefreshIfNeeded(expectedGuard: codexDashboardGuard) + } } if forceTokenUsage, self.openAIDashboardRequiresLogin { await self.refreshProvider(.codex) - await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) + await self.refreshCreditsNow(minimumSnapshotUpdatedAt: refreshStartedAt) } self.persistWidgetSnapshot(reason: "refresh") } + + if allowsStartupConnectivityRetry { + self.completeStartupConnectivityRetryPass(currentAttempt: startupConnectivityRetryAttempt ?? 0) + } + if refreshPhase == .startup { + self.scheduleMemoryPressureRelief() + } } /// For demo/testing: drop the snapshot so the loading animation plays, then restore the last snapshot. @@ -682,12 +715,15 @@ final class UsageStore { if Task.isCancelled { break } await self.refreshTokenUsage(provider, force: force) } + self.scheduleMemoryPressureRelief() } deinit { self.timerTask?.cancel() self.tokenTimerTask?.cancel() self.tokenRefreshSequenceTask?.cancel() + self.memoryPressureReliefTask?.cancel() + self.startupConnectivityRetryTask?.cancel() self.storageRefreshTask?.cancel() self.codexPlanHistoryBackfillTask?.cancel() } @@ -905,7 +941,9 @@ final class UsageStore { do { let status: ProviderStatus - if let urlString = meta.statusPageURL, let baseURL = URL(string: urlString) { + if let override = self._test_providerStatusFetchOverride { + status = try await override(provider) + } else if let urlString = meta.statusPageURL, let baseURL = URL(string: urlString) { status = try await Self.fetchStatus(from: baseURL) } else if let productID = meta.statusWorkspaceProductID { status = try await Self.fetchWorkspaceStatus(productID: productID) @@ -914,6 +952,7 @@ final class UsageStore { } await MainActor.run { self.statuses[provider] = status } } catch { + self.recordStartupConnectivityRetryableFailure(error) // Keep the previous status to avoid flapping when the API hiccups. await MainActor.run { if self.statuses[provider] == nil { @@ -928,491 +967,6 @@ final class UsageStore { } extension UsageStore { - func debugDumpClaude() async { - let fetcher = ClaudeUsageFetcher( - browserDetection: self.browserDetection, - keepCLISessionsAlive: self.settings.debugKeepCLISessionsAlive) - let output = await fetcher.debugRawProbe(model: "sonnet") - let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("codexbar-claude-probe.txt") - try? output.write(to: url, atomically: true, encoding: .utf8) - await MainActor.run { - let snippet = String(output.prefix(180)).replacingOccurrences(of: "\n", with: " ") - self.errors[.claude] = "[Claude] \(snippet) (saved: \(url.path))" - NSWorkspace.shared.open(url) - } - } - - func dumpLog(toFileFor provider: UsageProvider) async -> URL? { - let text = await self.debugLog(for: provider) - let filename = "codexbar-\(provider.rawValue)-probe.txt" - let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(filename) - do { - try text.write(to: url, atomically: true, encoding: .utf8) - _ = await MainActor.run { NSWorkspace.shared.open(url) } - return url - } catch { - await MainActor.run { - self.errors[provider] = "Failed to save log: \(error.localizedDescription)" - } - return nil - } - } - - func debugAugmentDump() async -> String { - await AugmentStatusProbe.latestDumps() - } - - // swiftlint:disable:next function_body_length - func debugLog(for provider: UsageProvider) async -> String { - if let cached = self.probeLogs[provider], !cached.isEmpty { - return cached - } - - let claudeWebExtrasEnabled = self.settings.claudeWebExtrasEnabled - let claudeUsageDataSource = self.settings.claudeUsageDataSource - let claudeCookieSource = self.settings.claudeCookieSource - let claudeCookieHeader = self.settings.claudeCookieHeader - let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude { - await self.makeClaudeDebugConfiguration( - fallbackUsageDataSource: claudeUsageDataSource, - fallbackWebExtrasEnabled: claudeWebExtrasEnabled, - fallbackCookieSource: claudeCookieSource, - fallbackCookieHeader: claudeCookieHeader) - } else { - nil - } - let cursorCookieSource = self.settings.cursorCookieSource - let cursorCookieHeader = self.settings.cursorCookieHeader - let ampCookieSource = self.settings.ampCookieSource - let ampCookieHeader = self.settings.ampCookieHeader - let ollamaCookieSource = self.settings.ollamaCookieSource - let ollamaCookieHeader = self.settings.ollamaCookieHeader - let processEnvironment = self.environmentBase - let openAIDebugContext = self.openAIAPIKeyDebugContext(processEnvironment: processEnvironment) - let azureOpenAIDebugContext = self.azureOpenAIAPIKeyDebugContext(processEnvironment: processEnvironment) - let openRouterDebugContext = self.openRouterAPIKeyDebugContext(processEnvironment: processEnvironment) - let elevenLabsDebugContext = self.elevenLabsAPIKeyDebugContext(processEnvironment: processEnvironment) - let deepSeekHasEnvToken = DeepSeekSettingsReader.apiKey(environment: processEnvironment) != nil - let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil - let deepSeekEnvironment = ProviderRegistry.makeEnvironment( - base: processEnvironment, - provider: .deepseek, - settings: self.settings, - tokenOverride: nil) - let codexFetcher = self.codexFetcher - let browserDetection = self.browserDetection - let claudeDebugExecutionContext = self.currentClaudeDebugExecutionContext() - let text = await Task.detached(priority: .utility) { () -> String in - let unimplementedDebugLogMessages: [UsageProvider: String] = [ - .gemini: "Gemini debug log not yet implemented", - .antigravity: "Antigravity debug log not yet implemented", - .opencode: "OpenCode debug log not yet implemented", - .alibaba: "Alibaba Coding Plan debug log not yet implemented", - .alibabatokenplan: "Alibaba Token Plan debug log not yet implemented", - .factory: "Droid debug log not yet implemented", - .copilot: "Copilot debug log not yet implemented", - .manus: "Manus debug log not yet implemented", - .vertexai: "Vertex AI debug log not yet implemented", - .kilo: "Kilo debug log not yet implemented", - .kiro: "Kiro debug log not yet implemented", - .kimi: "Kimi debug log not yet implemented", - .kimik2: "Kimi K2 debug log not yet implemented", - .jetbrains: "JetBrains AI debug log not yet implemented", - .mimo: "Xiaomi MiMo debug log not yet implemented", - .doubao: "Doubao debug log not yet implemented", - .venice: "Venice debug log not yet implemented", - .commandcode: "Command Code debug log not yet implemented", - .stepfun: "StepFun debug log not yet implemented", - .bedrock: "Bedrock debug log not yet implemented", - .grok: "Grok debug log not yet implemented", - .groq: "Groq debug log not yet implemented", - .t3chat: "T3 Chat debug log not yet implemented", - .llmproxy: "LLM Proxy debug log not yet implemented", - .deepgram: "Deepgram debug log not yet implemented", - ] - let buildText = { - switch provider { - case .codex: - return await codexFetcher.debugRawRateLimits() - case .openai: - return Self.apiKeyDebugLine(openAIDebugContext) - case .azureopenai: - return Self.apiKeyDebugLine(azureOpenAIDebugContext) - case .claude: - guard let claudeDebugConfiguration else { - return "Claude debug log configuration unavailable" - } - return await claudeDebugExecutionContext.apply { - await Self.debugClaudeLog( - browserDetection: browserDetection, - configuration: claudeDebugConfiguration) - } - case .zai: - let resolution = ProviderTokenResolver.zaiResolution() - let hasAny = resolution != nil - let source = resolution?.source.rawValue ?? "none" - return "Z_AI_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" - case .synthetic: - let resolution = ProviderTokenResolver.syntheticResolution() - let hasAny = resolution != nil - let source = resolution?.source.rawValue ?? "none" - return "SYNTHETIC_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" - case .cursor: - return await Self.debugCursorLog( - browserDetection: browserDetection, - cursorCookieSource: cursorCookieSource, - cursorCookieHeader: cursorCookieHeader) - case .minimax: - let tokenResolution = ProviderTokenResolver.minimaxTokenResolution() - let cookieResolution = ProviderTokenResolver.minimaxCookieResolution() - let tokenSource = tokenResolution?.source.rawValue ?? "none" - let cookieSource = cookieResolution?.source.rawValue ?? "none" - return "MINIMAX_API_KEY=\(tokenResolution == nil ? "missing" : "present") " + - "source=\(tokenSource) MINIMAX_COOKIE=\(cookieResolution == nil ? "missing" : "present") " + - "source=\(cookieSource)" - case .alibaba: - let resolution = ProviderTokenResolver.alibabaTokenResolution() - let hasAny = resolution != nil - let source = resolution?.source.rawValue ?? "none" - return "ALIBABA_CODING_PLAN_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" - case .augment: - return await Self.debugAugmentLog() - case .amp: - return await Self.debugAmpLog( - browserDetection: browserDetection, - ampCookieSource: ampCookieSource, - ampCookieHeader: ampCookieHeader) - case .ollama: - return await Self.debugOllamaLog( - browserDetection: browserDetection, - ollamaCookieSource: ollamaCookieSource, - ollamaCookieHeader: ollamaCookieHeader) - case .openrouter: - return Self.apiKeyDebugLine(openRouterDebugContext) - case .elevenlabs: - return Self.apiKeyDebugLine(elevenLabsDebugContext) - case .warp: - let resolution = ProviderTokenResolver.warpResolution() - let hasAny = resolution != nil - let source = resolution?.source.rawValue ?? "none" - return "WARP_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" - case .deepseek: - return Self.apiKeyDebugLine( - label: "DEEPSEEK_API_KEY", - resolution: ProviderTokenResolver.deepseekResolution(environment: deepSeekEnvironment), - configToken: nil, - hasEnvToken: deepSeekHasEnvToken, - hasTokenAccount: deepSeekHasTokenAccount) - case .gemini, .antigravity, .opencode, .opencodego, .alibabatokenplan, .factory, .copilot, - .vertexai, .kilo, .kiro, .kimi, .kimik2, .moonshot, .jetbrains, .perplexity, .mimo, .doubao, - .abacus, .mistral, .codebuff, .crof, .windsurf, .venice, .manus, .commandcode, .stepfun, .bedrock, - .grok, .groq, .t3chat, .llmproxy, .deepgram: - return unimplementedDebugLogMessages[provider] ?? "Debug log not yet implemented" - } - } - return await claudeDebugExecutionContext.apply { - await buildText() - } - }.value - self.probeLogs[provider] = text - return text - } - - private func makeClaudeDebugConfiguration( - fallbackUsageDataSource: ClaudeUsageDataSource, - fallbackWebExtrasEnabled: Bool, - fallbackCookieSource: ProviderCookieSource, - fallbackCookieHeader: String) async -> ClaudeDebugLogConfiguration - { - await MainActor.run { - let sourceMode = self.sourceMode(for: .claude) - let snapshot = ProviderRegistry.makeSettingsSnapshot(settings: self.settings, tokenOverride: nil) - let environment = ProviderRegistry.makeEnvironment( - base: self.environmentBase, - provider: .claude, - settings: self.settings, - tokenOverride: nil) - let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings( - usageDataSource: fallbackUsageDataSource, - webExtrasEnabled: fallbackWebExtrasEnabled, - cookieSource: fallbackCookieSource, - manualCookieHeader: fallbackCookieHeader) - return ClaudeDebugLogConfiguration( - runtime: CodexBarCore.ProviderRuntime.app, - sourceMode: sourceMode, - environment: environment, - webExtrasEnabled: claudeSettings.webExtrasEnabled, - usageDataSource: claudeSettings.usageDataSource, - cookieSource: claudeSettings.cookieSource, - cookieHeader: claudeSettings.manualCookieHeader ?? "", - keepCLISessionsAlive: snapshot.debugKeepCLISessionsAlive) - } - } - - private struct ClaudeDebugExecutionContext { - let interaction: ProviderInteraction - let refreshPhase: ProviderRefreshPhase - #if DEBUG - let keychainServiceOverride: String? - let credentialsURLOverride: URL? - let testingOverrides: ClaudeOAuthCredentialsStore.TestingOverridesSnapshot - let keychainDeniedUntilStoreOverride: ClaudeOAuthKeychainAccessGate.DeniedUntilStore? - let keychainPromptModeOverride: ClaudeOAuthKeychainPromptMode? - let keychainReadStrategyOverride: ClaudeOAuthKeychainReadStrategy? - let cliPathOverride: String? - let statusFetchOverride: ClaudeStatusProbe.FetchOverride? - #endif - - func apply(_ operation: () async -> T) async -> T { - await ProviderInteractionContext.$current.withValue(self.interaction) { - await ProviderRefreshContext.$current.withValue(self.refreshPhase) { - #if DEBUG - return await KeychainCacheStore.withServiceOverrideForTesting(self.keychainServiceOverride) { - await ClaudeOAuthCredentialsStore - .withCredentialsURLOverrideForTesting(self.credentialsURLOverride) { - await ClaudeOAuthCredentialsStore - .withTestingOverridesSnapshotForTask(self.testingOverrides) { - await ClaudeOAuthKeychainAccessGate - .withDeniedUntilStoreOverrideForTesting(self - .keychainDeniedUntilStoreOverride) - { - await ClaudeOAuthKeychainPromptPreference - .withTaskOverrideForTesting(self.keychainPromptModeOverride) { - await ClaudeOAuthKeychainReadStrategyPreference - .withTaskOverrideForTesting(self - .keychainReadStrategyOverride) - { - await ClaudeCLIResolver - .withResolvedBinaryPathOverrideForTesting(self - .cliPathOverride) - { - await ClaudeStatusProbe - .withFetchOverrideForTesting(self - .statusFetchOverride) - { - await operation() - } - } - } - } - } - } - } - } - #else - return await operation() - #endif - } - } - } - } - - private func currentClaudeDebugExecutionContext() -> ClaudeDebugExecutionContext { - #if DEBUG - ClaudeDebugExecutionContext( - interaction: ProviderInteractionContext.current, - refreshPhase: ProviderRefreshContext.current, - keychainServiceOverride: KeychainCacheStore.currentServiceOverrideForTesting, - credentialsURLOverride: ClaudeOAuthCredentialsStore.currentCredentialsURLOverrideForTesting, - testingOverrides: ClaudeOAuthCredentialsStore.currentTestingOverridesSnapshotForTask, - keychainDeniedUntilStoreOverride: ClaudeOAuthKeychainAccessGate.currentDeniedUntilStoreOverrideForTesting, - keychainPromptModeOverride: ClaudeOAuthKeychainPromptPreference.currentTaskOverrideForTesting, - keychainReadStrategyOverride: ClaudeOAuthKeychainReadStrategyPreference.currentTaskOverrideForTesting, - cliPathOverride: ClaudeCLIResolver.currentResolvedBinaryPathOverrideForTesting, - statusFetchOverride: ClaudeStatusProbe.currentFetchOverrideForTesting) - #else - ClaudeDebugExecutionContext( - interaction: ProviderInteractionContext.current, - refreshPhase: ProviderRefreshContext.current) - #endif - } - - private struct APIKeyDebugContext { - let label: String - let resolution: ProviderTokenResolution? - let configToken: String? - let hasEnvToken: Bool - let hasTokenAccount: Bool - } - - private func openAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .openai) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .openai, - config: config) - return APIKeyDebugContext( - label: "OPENAI_API_KEY", - resolution: ProviderTokenResolver.openAIAPIResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: OpenAIAPISettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func azureOpenAIAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .azureopenai) - let environment = ProviderConfigEnvironment.applyProviderConfigOverrides( - base: processEnvironment, - provider: .azureopenai, - config: config) - return APIKeyDebugContext( - label: "AZURE_OPENAI_API_KEY", - resolution: ProviderTokenResolver.azureOpenAIResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: AzureOpenAISettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func openRouterAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .openrouter) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .openrouter, - config: config) - return APIKeyDebugContext( - label: "OPENROUTER_API_KEY", - resolution: ProviderTokenResolver.openRouterResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: OpenRouterSettingsReader.apiToken(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private func elevenLabsAPIKeyDebugContext(processEnvironment: [String: String]) -> APIKeyDebugContext { - let config = self.settings.providerConfig(for: .elevenlabs) - let environment = ProviderConfigEnvironment.applyAPIKeyOverride( - base: processEnvironment, - provider: .elevenlabs, - config: config) - return APIKeyDebugContext( - label: "ELEVENLABS_API_KEY", - resolution: ProviderTokenResolver.elevenLabsResolution(environment: environment), - configToken: config?.sanitizedAPIKey, - hasEnvToken: ElevenLabsSettingsReader.apiKey(environment: processEnvironment) != nil, - hasTokenAccount: false) - } - - private nonisolated static func apiKeyDebugLine(_ context: APIKeyDebugContext) -> String { - self.apiKeyDebugLine( - label: context.label, - resolution: context.resolution, - configToken: context.configToken, - hasEnvToken: context.hasEnvToken, - hasTokenAccount: context.hasTokenAccount) - } - - private nonisolated static func apiKeyDebugLine( - label: String, - resolution: ProviderTokenResolution?, - configToken: String?, - hasEnvToken: Bool, - hasTokenAccount: Bool = false) -> String - { - let hasAny = resolution != nil - let hasConfigToken = !(configToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) - let source: String = if resolution == nil { - "none" - } else if hasTokenAccount, hasEnvToken { - "settings-token-account (overrides env)" - } else if hasTokenAccount { - "settings-token-account" - } else if hasConfigToken, hasEnvToken { - "settings-config (overrides env)" - } else if hasConfigToken { - "settings-config" - } else { - resolution?.source.rawValue ?? "environment" - } - return "\(label)=\(hasAny ? "present" : "missing") source=\(source)" - } - - private static func debugCursorLog( - browserDetection: BrowserDetection, - cursorCookieSource: ProviderCookieSource, - cursorCookieHeader: String) async -> String - { - await runWithTimeout(seconds: 15) { - var lines: [String] = [] - - do { - let probe = CursorStatusProbe(browserDetection: browserDetection) - let snapshot: CursorStatusSnapshot = if cursorCookieSource == .manual, - let normalizedHeader = CookieHeaderNormalizer - .normalize(cursorCookieHeader) - { - try await probe.fetchWithManualCookies(normalizedHeader) - } else { - try await probe.fetch { msg in lines.append("[cursor-cookie] \(msg)") } - } - - lines.append("") - lines.append("Cursor Status Summary:") - lines.append("membershipType=\(snapshot.membershipType ?? "nil")") - lines.append("accountEmail=\(EmailRedaction.redact(snapshot.accountEmail))") - lines.append("planPercentUsed=\(snapshot.planPercentUsed)%") - lines.append("planUsedUSD=$\(snapshot.planUsedUSD)") - lines.append("planLimitUSD=$\(snapshot.planLimitUSD)") - lines.append("onDemandUsedUSD=$\(snapshot.onDemandUsedUSD)") - lines.append("onDemandLimitUSD=\(snapshot.onDemandLimitUSD.map { "$\($0)" } ?? "nil")") - if let teamUsed = snapshot.teamOnDemandUsedUSD { - lines.append("teamOnDemandUsedUSD=$\(teamUsed)") - } - if let teamLimit = snapshot.teamOnDemandLimitUSD { - lines.append("teamOnDemandLimitUSD=$\(teamLimit)") - } - lines.append("billingCycleEnd=\(snapshot.billingCycleEnd?.description ?? "nil")") - - if let rawJSON = snapshot.rawJSON { - lines.append("") - lines.append("Raw API Response:") - lines.append(rawJSON) - } - - return lines.joined(separator: "\n") - } catch { - lines.append("") - lines.append("Cursor probe failed: \(error.localizedDescription)") - return lines.joined(separator: "\n") - } - } - } - - private static func debugAugmentLog() async -> String { - await runWithTimeout(seconds: 15) { - let probe = AugmentStatusProbe() - return await probe.debugRawProbe() - } - } - - private static func debugAmpLog( - browserDetection: BrowserDetection, - ampCookieSource: ProviderCookieSource, - ampCookieHeader: String) async -> String - { - await runWithTimeout(seconds: 15) { - let fetcher = AmpUsageFetcher(browserDetection: browserDetection) - let manualHeader = ampCookieSource == .manual - ? CookieHeaderNormalizer.normalize(ampCookieHeader) - : nil - return await fetcher.debugRawProbe(cookieHeaderOverride: manualHeader) - } - } - - private static func debugOllamaLog( - browserDetection: BrowserDetection, - ollamaCookieSource: ProviderCookieSource, - ollamaCookieHeader: String) async -> String - { - await runWithTimeout(seconds: 15) { - let fetcher = OllamaUsageFetcher(browserDetection: browserDetection) - let manualHeader = ollamaCookieSource == .manual - ? CookieHeaderNormalizer.normalize(ollamaCookieHeader) - : nil - return await fetcher.debugRawProbe( - cookieHeaderOverride: manualHeader, - manualCookieMode: ollamaCookieSource == .manual) - } - } - private func detectVersions() { let implementations = ProviderCatalog.all let browserDetection = self.browserDetection @@ -1497,7 +1051,7 @@ extension UsageStore { } private func refreshTokenUsage(_ provider: UsageProvider, force: Bool) async { - guard provider == .codex || provider == .claude || provider == .vertexai || provider == .bedrock else { + guard ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.supportsTokenCost else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil self.tokenFailureGates[provider]?.reset() @@ -1511,6 +1065,20 @@ extension UsageStore { return } + if Self.tokenCostRequiresProviderSnapshot(provider) { + if let snapshot = self.tokenSnapshot(fromProviderSnapshot: self.snapshots[provider], provider: provider) { + self.tokenSnapshots[provider] = snapshot + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.recordSuccess() + self.persistWidgetSnapshot(reason: "token-usage") + } else { + self.tokenSnapshots.removeValue(forKey: provider) + self.tokenErrors[provider] = nil + self.tokenFailureGates[provider]?.reset() + } + return + } + guard self.settings.costUsageEnabled else { self.tokenSnapshots.removeValue(forKey: provider) self.tokenErrors[provider] = nil @@ -1562,9 +1130,9 @@ extension UsageStore { settings: self.settings, tokenOverride: nil) : self.environmentBase - // CostUsageFetcher scans local Codex session logs from this machine. That data is + // Codex cost usage scans local session logs from this machine. That data is // intentionally presented as provider-level local telemetry rather than managed-account - // remote state, so managed Codex account selection does not retarget this fetch. + // remote state, so managed Codex account selection does not retarget that fetch. // If the UI later needs account-scoped token history, it should label and source that // separately instead of silently changing the meaning of this section. let snapshot = try await withThrowingTaskGroup(of: CostUsageTokenSnapshot.self) { group in @@ -1594,8 +1162,10 @@ extension UsageStore { return } let duration = Date().timeIntervalSince(startedAt) - let sessionCost = snapshot.sessionCostUSD.map(UsageFormatter.usdString) ?? "—" - let monthCost = snapshot.last30DaysCostUSD.map(UsageFormatter.usdString) ?? "—" + let sessionCost = snapshot.sessionCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" + let monthCost = snapshot.last30DaysCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" let durationText = String(format: "%.2f", duration) let message = "cost usage success provider=\(providerText) " + diff --git a/Sources/CodexBar/ZaiHourlyUsageChartMenuView.swift b/Sources/CodexBar/ZaiHourlyUsageChartMenuView.swift index 47bf08f1..4e1ec437 100644 --- a/Sources/CodexBar/ZaiHourlyUsageChartMenuView.swift +++ b/Sources/CodexBar/ZaiHourlyUsageChartMenuView.swift @@ -61,7 +61,7 @@ struct ZaiHourlyUsageChartMenuView: View { }) .buttonStyle(.plain) - Text("Hourly Tokens") + Text(L("Hourly Tokens")) .font(.system(size: 11, weight: .semibold)) .foregroundColor(.primary) @@ -75,7 +75,7 @@ struct ZaiHourlyUsageChartMenuView: View { if self.isExpanded { VStack(alignment: .leading, spacing: 4) { if self.bars.isEmpty { - Text("No data") + Text(L("No data")) .font(.system(size: 10)) .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .center) @@ -131,7 +131,7 @@ struct ZaiHourlyUsageChartMenuView: View { get: { self.selectedRange.rawValue }, set: { self.selectedRange = RangeOption(rawValue: $0) ?? .today })) { - Text("Today").tag(RangeOption.today.rawValue) + Text(L("Today")).tag(RangeOption.today.rawValue) Text("24h").tag(RangeOption.last24h.rawValue) } .pickerStyle(.segmented) diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index 9b503e19..7f63b241 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -84,13 +84,16 @@ extension CodexBarCLI { let name = ProviderDescriptorRegistry.descriptor(for: provider).metadata.displayName let header = Self.costHeaderLine("\(name) Cost (API-rate estimate)", useColor: useColor) - let todayCost = snapshot.sessionCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let todayCost = snapshot.sessionCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" let todayTokens = snapshot.sessionTokens.map { UsageFormatter.tokenCountString($0) } let todayLine = todayTokens.map { "Today: \(todayCost) · \($0) tokens" } ?? "Today: \(todayCost)" - let monthCost = snapshot.last30DaysCostUSD.map { UsageFormatter.usdString($0) } ?? "—" + let monthCost = snapshot.last30DaysCostUSD + .map { UsageFormatter.currencyString($0, currencyCode: snapshot.currencyCode) } ?? "—" let monthTokens = snapshot.last30DaysTokens.map { UsageFormatter.tokenCountString($0) } - let historyLabel = snapshot.historyDays == 1 ? "Today" : "Last \(snapshot.historyDays) days" + let historyLabel = snapshot.historyLabel + ?? (snapshot.historyDays == 1 ? "Today" : "Last \(snapshot.historyDays) days") let monthLine = monthTokens.map { "\(historyLabel): \(monthCost) · \($0) tokens" } ?? "\(historyLabel): \(monthCost)" @@ -135,6 +138,7 @@ extension CodexBarCLI { provider: provider.rawValue, source: "local", updatedAt: snapshot?.updatedAt ?? (error == nil ? nil : Date()), + currencyCode: snapshot?.currencyCode, sessionTokens: snapshot?.sessionTokens, sessionCostUSD: snapshot?.sessionCostUSD, historyDays: snapshot?.historyDays, @@ -257,6 +261,7 @@ struct CostPayload: Encodable { let provider: String let source: String let updatedAt: Date? + let currencyCode: String? let sessionTokens: Int? let sessionCostUSD: Double? let historyDays: Int? @@ -265,6 +270,34 @@ struct CostPayload: Encodable { let daily: [CostDailyEntryPayload] let totals: CostTotalsPayload? let error: ProviderErrorPayload? + + init( + provider: String, + source: String, + updatedAt: Date?, + currencyCode: String? = nil, + sessionTokens: Int?, + sessionCostUSD: Double?, + historyDays: Int?, + last30DaysTokens: Int?, + last30DaysCostUSD: Double?, + daily: [CostDailyEntryPayload], + totals: CostTotalsPayload?, + error: ProviderErrorPayload?) + { + self.provider = provider + self.source = source + self.updatedAt = updatedAt + self.currencyCode = currencyCode + self.sessionTokens = sessionTokens + self.sessionCostUSD = sessionCostUSD + self.historyDays = historyDays + self.last30DaysTokens = last30DaysTokens + self.last30DaysCostUSD = last30DaysCostUSD + self.daily = daily + self.totals = totals + self.error = error + } } struct CostDailyEntryPayload: Encodable { diff --git a/Sources/CodexBarCLI/CLIDiagnoseCommand.swift b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift new file mode 100644 index 00000000..9725f2d1 --- /dev/null +++ b/Sources/CodexBarCLI/CLIDiagnoseCommand.swift @@ -0,0 +1,312 @@ +import CodexBarCore +import Commander +import Foundation + +extension CodexBarCLI { + static func runDiagnose(_ values: ParsedValues) async { + let output = CLIOutputPreferences.from(values: values) + let config = Self.loadConfig(output: output) + + let format = Self.decodeFormat(from: values) + guard format == .json else { + Self.exit( + code: .failure, + message: "Error: only JSON format is supported for diagnose", + output: output, + kind: .args) + } + + let providerSelection: ProviderSelection + if let rawProvider = values.options["provider"]?.last { + guard let parsed = ProviderSelection(argument: rawProvider) else { + Self.exit( + code: .failure, + message: "Error: unknown provider '\(rawProvider)'", + output: output, + kind: .args) + } + providerSelection = parsed + } else { + providerSelection = Self.providerSelection(rawOverride: nil, enabled: config.enabledProviders()) + } + + let providers = providerSelection.asList + let pretty = values.flags.contains("pretty") + let verbose = values.flags.contains("verbose") + let browserDetection = BrowserDetection() + let baseFetcher = UsageFetcher() + + let tokenSelection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext: TokenAccountCLIContext + do { + tokenContext = try TokenAccountCLIContext( + selection: tokenSelection, + config: config, + verbose: verbose) + } catch { + Self.exit(code: .failure, message: "Error: \(error.localizedDescription)", output: output, kind: .config) + } + + var diagnostics: [ProviderDiagnosticExport] = [] + diagnostics.reserveCapacity(providers.count) + for provider in providers { + await diagnostics.append(Self.makeDiagnosticExport( + provider: provider, + tokenContext: tokenContext, + baseFetcher: baseFetcher, + browserDetection: browserDetection, + verbose: verbose)) + } + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = pretty ? [.prettyPrinted, .sortedKeys] : .sortedKeys + + do { + let data: Data = if diagnostics.count == 1, let diagnostic = diagnostics.first { + try encoder.encode(diagnostic) + } else { + try encoder.encode(ProviderDiagnosticBatchExport( + timestamp: Date(), + diagnostics: diagnostics)) + } + var jsonString = String(data: data, encoding: .utf8) ?? "{}" + jsonString = LogRedactor.redact(jsonString) + print(jsonString) + } catch { + Self.exit( + code: .failure, + message: "Error encoding diagnostic: \(error.localizedDescription)", + output: output, + kind: .runtime) + } + + Self.exit(code: .success, output: output, kind: .runtime) + } +} + +extension CodexBarCLI { + private static func makeDiagnosticExport( + provider: UsageProvider, + tokenContext: TokenAccountCLIContext, + baseFetcher: UsageFetcher, + browserDetection: BrowserDetection, + verbose: Bool) async -> ProviderDiagnosticExport + { + let account = ((try? tokenContext.resolvedAccounts(for: provider)) ?? []).first + let env = tokenContext.environment( + base: ProcessInfo.processInfo.environment, + provider: provider, + account: account, + codexActiveSourceOverride: nil) + let settings = tokenContext.settingsSnapshot( + for: provider, + account: account, + codexActiveSourceOverride: nil) + let preferredSourceMode = tokenContext.preferredSourceMode(for: provider) + let sourceMode = tokenContext.effectiveSourceMode( + base: preferredSourceMode, + provider: provider, + account: account) + let fetcher = tokenContext.fetcher(base: baseFetcher, provider: provider, env: env) + let fetchContext = ProviderFetchContext( + runtime: .cli, + sourceMode: sourceMode, + includeCredits: true, + includeOptionalUsage: true, + webTimeout: 60, + webDebugDumpHTML: false, + verbose: verbose, + env: env, + settings: settings, + fetcher: fetcher, + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + selectedTokenAccountID: account?.id, + tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account), + providerManualTokenUpdater: tokenContext.manualTokenUpdater()) + let descriptor = ProviderDescriptorRegistry.descriptor(for: provider) + let outcome = await Self.fetchProviderUsage(provider: provider, context: fetchContext) + return ProviderDiagnosticExportBuilder.build(.init( + provider: provider, + descriptor: descriptor, + outcome: outcome, + sourceMode: sourceMode, + settings: settings, + auth: Self.diagnosticAuthSummary( + provider: provider, + account: account, + config: tokenContext.config.providerConfig(for: provider), + environment: env, + settings: settings))) + } + + static func diagnosticAuthSummary( + provider: UsageProvider, + account: ProviderTokenAccount?, + config: ProviderConfig?, + environment: [String: String], + settings: ProviderSettingsSnapshot?) -> ProviderDiagnosticAuthSummary + { + if provider == .minimax { + let authMode = self.resolveMiniMaxAuthMode(environment: environment, settings: settings) + return ProviderDiagnosticAuthSummary( + configured: authMode.usesAPIToken || authMode.usesCookie, + modes: authMode == .none ? [] : [authMode.description]) + } + + var modes: [String] = [] + if account != nil { + modes.append("tokenAccount") + } + let hasConfigAPIAuth = if provider == .bedrock { + config?.sanitizedAPIKey != nil && config?.sanitizedSecretKey != nil + } else { + config?.sanitizedAPIKey != nil || config?.sanitizedSecretKey != nil + } + if hasConfigAPIAuth { + modes.append("api") + } + if Self.environmentAPIAuthConfigured(provider: provider, environment: environment), !modes.contains("api") { + modes.append("api") + } + if config?.sanitizedCookieHeader != nil { + modes.append("web") + } + if Self.environmentWebAuthConfigured(provider: provider, environment: environment), !modes.contains("web") { + modes.append("web") + } + return ProviderDiagnosticAuthSummary( + configured: !modes.isEmpty, + modes: modes) + } + + private static func environmentAPIAuthConfigured( + provider: UsageProvider, + environment: [String: String]) -> Bool + { + self.environmentCoreAPIAuthConfigured(provider: provider, environment: environment) || + self.environmentExtendedAPIAuthConfigured(provider: provider, environment: environment) + } + + private static func environmentCoreAPIAuthConfigured( + provider: UsageProvider, + environment: [String: String]) -> Bool + { + switch provider { + case .alibaba: + AlibabaCodingPlanSettingsReader.apiToken(environment: environment) != nil + case .azureopenai: + AzureOpenAISettingsReader.apiKey(environment: environment) != nil + case .bedrock: + BedrockSettingsReader.hasCredentials(environment: environment) + case .claude: + ClaudeAdminAPISettingsReader.apiKey(environment: environment) != nil + case .codebuff: + CodebuffSettingsReader.apiKey(environment: environment) != nil + case .crof: + CrofSettingsReader.apiKey(environment: environment) != nil + case .deepgram: + DeepgramSettingsReader.apiKey(environment: environment) != nil + case .deepseek: + DeepSeekSettingsReader.apiKey(environment: environment) != nil + case .doubao: + DoubaoSettingsReader.apiKey(environment: environment) != nil + case .elevenlabs: + ElevenLabsSettingsReader.apiKey(environment: environment) != nil + case .groq: + GroqSettingsReader.apiKey(environment: environment) != nil + case .kilo: + KiloSettingsReader.apiKey(environment: environment) != nil + default: + false + } + } + + private static func environmentExtendedAPIAuthConfigured( + provider: UsageProvider, + environment: [String: String]) -> Bool + { + switch provider { + case .kimik2: + KimiK2SettingsReader.apiKey(environment: environment) != nil + case .llmproxy: + LLMProxySettingsReader.apiKey(environment: environment) != nil + case .moonshot: + MoonshotSettingsReader.apiKey(environment: environment) != nil + case .ollama: + OllamaAPISettingsReader.apiKey(environment: environment) != nil + case .openai: + OpenAIAPISettingsReader.apiKey(environment: environment) != nil + case .openrouter: + OpenRouterSettingsReader.apiToken(environment: environment) != nil + case .stepfun: + StepFunSettingsReader.token(environment: environment) != nil + case .synthetic: + SyntheticSettingsReader.apiKey(environment: environment) != nil + case .venice: + VeniceSettingsReader.apiKey(environment: environment) != nil + case .warp: + WarpSettingsReader.apiKey(environment: environment) != nil + case .zai: + ZaiSettingsReader.apiToken(environment: environment) != nil + default: + false + } + } + + private static func environmentWebAuthConfigured( + provider: UsageProvider, + environment: [String: String]) -> Bool + { + switch provider { + case .alibabatokenplan: + AlibabaTokenPlanSettingsReader.cookieHeader(environment: environment) != nil + case .kimi: + KimiSettingsReader.authToken(environment: environment) != nil + case .manus: + ManusSettingsReader.sessionToken(environment: environment) != nil + case .perplexity: + PerplexitySettingsReader.sessionToken(environment: environment) != nil + default: + false + } + } + + static func resolveMiniMaxAuthMode( + environment: [String: String], + settings: ProviderSettingsSnapshot?) -> MiniMaxAuthMode + { + let apiToken = ProviderTokenResolver.minimaxToken(environment: environment) + let envCookieHeader = ProviderTokenResolver.minimaxCookie(environment: environment) + let settingsCookieHeader = CookieHeaderNormalizer.normalize(settings?.minimax?.manualCookieHeader) + let cookieHeader = envCookieHeader ?? settingsCookieHeader + return MiniMaxAuthMode.resolve(apiToken: apiToken, cookieHeader: cookieHeader) + } +} + +#if DEBUG +extension CodexBarCLI { + static func _diagnosticAuthSummaryForTesting( + provider: UsageProvider, + account: ProviderTokenAccount?, + config: ProviderConfig?, + environment: [String: String], + settings: ProviderSettingsSnapshot?) -> ProviderDiagnosticAuthSummary + { + self.diagnosticAuthSummary( + provider: provider, + account: account, + config: config, + environment: environment, + settings: settings) + } + + static func _resolveMiniMaxAuthModeForTesting( + environment: [String: String], + settings: ProviderSettingsSnapshot?) -> MiniMaxAuthMode + { + self.resolveMiniMaxAuthMode(environment: environment, settings: settings) + } +} +#endif diff --git a/Sources/CodexBarCLI/CLIEntry.swift b/Sources/CodexBarCLI/CLIEntry.swift index f6b7aa06..35eb55b2 100644 --- a/Sources/CodexBarCLI/CLIEntry.swift +++ b/Sources/CodexBarCLI/CLIEntry.swift @@ -30,7 +30,7 @@ enum CodexBarCLI { do { let invocation = try program.resolve(argv: argv) - Self.bootstrapLogging(values: invocation.parsedValues) + Self.bootstrapLogging(path: invocation.path, values: invocation.parsedValues) switch invocation.path { case ["usage"]: await self.runUsage(invocation.parsedValues) @@ -52,6 +52,8 @@ enum CodexBarCLI { self.runConfigSetAPIKey(invocation.parsedValues) case ["cache", "clear"]: self.runCacheClear(invocation.parsedValues) + case ["diagnose"]: + await self.runDiagnose(invocation.parsedValues) default: Self.exit( code: .failure, @@ -74,6 +76,7 @@ enum CodexBarCLI { let configProviderToggleSignature = CommandSignature.describe(ConfigProviderToggleOptions()) let configSetAPIKeySignature = CommandSignature.describe(ConfigSetAPIKeyOptions()) let cacheSignature = CommandSignature.describe(CacheOptions()) + let diagnoseSignature = CommandSignature.describe(DiagnoseOptions()) return [ CommandDescriptor( @@ -142,17 +145,27 @@ enum CodexBarCLI { signature: cacheSignature), ], defaultSubcommandName: "clear"), + CommandDescriptor( + name: "diagnose", + abstract: "Run provider diagnostic and emit safe JSON export", + discussion: nil, + signature: diagnoseSignature), ] } // MARK: - Helpers - private static func bootstrapLogging(values: ParsedValues) { + private static func bootstrapLogging(path: [String], values: ParsedValues) { + CodexBarLog.bootstrapIfNeeded(self.loggingConfiguration(path: path, values: values)) + } + + static func loggingConfiguration(path: [String], values: ParsedValues) -> CodexBarLog.Configuration { let isJSON = values.flags.contains("jsonOutput") || values.flags.contains("jsonOnly") let verbose = values.flags.contains("verbose") let rawLevel = values.options["logLevel"]?.last let level = Self.resolvedLogLevel(verbose: verbose, rawLevel: rawLevel) - CodexBarLog.bootstrapIfNeeded(.init(destination: .stderr, level: level, json: isJSON)) + let destination: CodexBarLog.Destination = path == ["diagnose"] ? .discard : .stderr + return .init(destination: destination, level: level, json: isJSON) } static func resolvedLogLevel(verbose: Bool, rawLevel: String?) -> CodexBarLog.Level { diff --git a/Sources/CodexBarCLI/CLIHelp.swift b/Sources/CodexBarCLI/CLIHelp.swift index 08325948..27e70552 100644 --- a/Sources/CodexBarCLI/CLIHelp.swift +++ b/Sources/CodexBarCLI/CLIHelp.swift @@ -168,6 +168,28 @@ extension CodexBarCLI { """ } + static func diagnoseHelp(version: String) -> String { + """ + CodexBar \(version) + + Usage: + codexbar diagnose --provider --format json + [--json-output] [--log-level ] + [-v|--verbose] + [--pretty] + + Description: + Run provider diagnostic fetches and print a safe JSON export for issue reporting. + The export is redacted and omits raw API tokens, cookies, auth headers, emails, + account IDs, org IDs, raw responses, and billing-history records. + + Examples: + codexbar diagnose --provider minimax --format json --pretty + codexbar diagnose --provider claude --format json --pretty + codexbar diagnose --provider all --format json + """ + } + static func rootHelp(version: String) -> String { """ CodexBar \(version) @@ -198,6 +220,7 @@ extension CodexBarCLI { codexbar config disable --provider codexbar config set-api-key --provider (--api-key |--stdin) codexbar cache clear <--cookies|--cost|--all> [--provider ] + codexbar diagnose --provider --format json [--pretty] Global flags: -h, --help Show help @@ -218,6 +241,8 @@ extension CodexBarCLI { codexbar config enable --provider grok codexbar config set-api-key --provider elevenlabs --stdin codexbar cache clear --cookies + codexbar diagnose --provider minimax --format json --pretty + codexbar diagnose --provider all --format json """ } } diff --git a/Sources/CodexBarCLI/CLIHelpers.swift b/Sources/CodexBarCLI/CLIHelpers.swift index c4444d8c..3ec5f3e9 100644 --- a/Sources/CodexBarCLI/CLIHelpers.swift +++ b/Sources/CodexBarCLI/CLIHelpers.swift @@ -366,6 +366,10 @@ extension CodexBarCLI { CommandSignature.describe(CacheOptions()) } + static func _diagnoseSignatureForTesting() -> CommandSignature { + CommandSignature.describe(DiagnoseOptions()) + } + static func _configSetAPIKeySignatureForTesting() -> CommandSignature { CommandSignature.describe(ConfigSetAPIKeyOptions()) } diff --git a/Sources/CodexBarCLI/CLIIO.swift b/Sources/CodexBarCLI/CLIIO.swift index 49242dab..1e371fd1 100644 --- a/Sources/CodexBarCLI/CLIIO.swift +++ b/Sources/CodexBarCLI/CLIIO.swift @@ -33,6 +33,8 @@ extension CodexBarCLI { print(Self.configHelp(version: version)) case "cache", "clear": print(Self.cacheHelp(version: version)) + case "diagnose": + print(Self.diagnoseHelp(version: version)) default: print(Self.rootHelp(version: version)) } diff --git a/Sources/CodexBarCLI/CLILocalHTTPServer.swift b/Sources/CodexBarCLI/CLILocalHTTPServer.swift index 65a486db..701ef09a 100644 --- a/Sources/CodexBarCLI/CLILocalHTTPServer.swift +++ b/Sources/CodexBarCLI/CLILocalHTTPServer.swift @@ -134,7 +134,7 @@ enum CLIHTTPStatus { case notFound case methodNotAllowed case internalServerError - + case gatewayTimeout var code: Int { switch self { case .ok: 200 @@ -143,6 +143,7 @@ enum CLIHTTPStatus { case .notFound: 404 case .methodNotAllowed: 405 case .internalServerError: 500 + case .gatewayTimeout: 504 } } @@ -154,6 +155,7 @@ enum CLIHTTPStatus { case .notFound: "Not Found" case .methodNotAllowed: "Method Not Allowed" case .internalServerError: "Internal Server Error" + case .gatewayTimeout: "Gateway Timeout" } } } diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index a5171ea9..ddc43514 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -166,8 +166,11 @@ enum CLIRenderer { showsTertiary: true) } + let primaryLabel = provider == .grok + ? GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel + : metadata.sessionLabel return RateWindowLabels( - primary: metadata.sessionLabel, + primary: primaryLabel, secondary: metadata.weeklyLabel, tertiary: metadata.opusLabel ?? "Sonnet", showsTertiary: metadata.supportsOpus) @@ -365,7 +368,10 @@ enum CLIRenderer { useColor: Bool, now: Date) -> String? { - guard provider == .codex || provider == .claude || provider == .opencode else { return nil } + guard provider == .codex || provider == .claude || provider == .opencode || provider == .ollama else { + return nil + } + if provider == .ollama, window.windowMinutes == nil { return nil } guard window.remainingPercent > 0 else { return nil } guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) else { return nil } guard pace.expectedUsedPercent >= Self.paceMinimumExpectedPercent else { return nil } diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index 569780b4..460ee310 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -17,6 +17,11 @@ struct ServeOptions: CommanderParsable { @Option(name: .long("refresh-interval"), help: "Response cache TTL in seconds (default: 60)") var refreshInterval: Double? + + @Option( + name: .long("request-timeout"), + help: "Total per-request deadline in seconds; 0 disables (default: 30)") + var requestTimeout: Double? } enum CLIServeRoute: Equatable { @@ -60,15 +65,82 @@ private struct ServeHealthPayload: Encodable { let status: String } -private actor CLIServeResponseCache { +private final class CLIServeDeadlineState: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var workTask: Task? + private var timeoutTask: Task? + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func setWorkTask(_ task: Task) { + var shouldCancel = false + self.lock.lock() + if self.continuation == nil { + shouldCancel = true + } else { + self.workTask = task + } + self.lock.unlock() + + if shouldCancel { + task.cancel() + } + } + + func setTimeoutTask(_ task: Task) { + var shouldCancel = false + self.lock.lock() + if self.continuation == nil { + shouldCancel = true + } else { + self.timeoutTask = task + } + self.lock.unlock() + + if shouldCancel { + task.cancel() + } + } + + func finish(_ response: CLILocalHTTPResponse, cancelWork: Bool, cancelTimeout: Bool) { + let continuation: CheckedContinuation? + let workTask: Task? + let timeoutTask: Task? + + self.lock.lock() + continuation = self.continuation + self.continuation = nil + workTask = cancelWork ? self.workTask : nil + timeoutTask = cancelTimeout ? self.timeoutTask : nil + self.workTask = nil + self.timeoutTask = nil + self.lock.unlock() + + workTask?.cancel() + timeoutTask?.cancel() + continuation?.resume(returning: response) + } +} + +private enum CLIServeCacheLookup { + case response(CLILocalHTTPResponse) + case miss +} + +actor CLIServeResponseCache { private struct Entry { let expiresAt: Date let response: CLILocalHTTPResponse } private var entries: [String: Entry] = [:] + private var inFlightKeys: Set = [] + private var waiters: [String: [CheckedContinuation]] = [:] - func response(for key: String, now: Date) -> CLILocalHTTPResponse? { + private func response(for key: String, now: Date) -> CLILocalHTTPResponse? { guard let entry = self.entries[key] else { return nil } guard entry.expiresAt > now else { self.entries[key] = nil @@ -77,7 +149,39 @@ private actor CLIServeResponseCache { return entry.response } - func store(_ response: CLILocalHTTPResponse, for key: String, ttl: TimeInterval, now: Date) { + fileprivate func responseOrStartFetch(for key: String, now: Date) async -> CLIServeCacheLookup { + if let cached = self.response(for: key, now: now) { + return .response(cached) + } + + if self.inFlightKeys.contains(key) { + return await withCheckedContinuation { continuation in + self.waiters[key, default: []].append(continuation) + } + } + + self.inFlightKeys.insert(key) + return .miss + } + + fileprivate func completeFetch( + _ response: CLILocalHTTPResponse, + for key: String, + ttl: TimeInterval, + now: Date, + shouldCache: Bool) + { + if shouldCache { + self.store(response, for: key, ttl: ttl, now: now) + } + self.inFlightKeys.remove(key) + let waiters = self.waiters.removeValue(forKey: key) ?? [] + for waiter in waiters { + waiter.resume(returning: .response(response)) + } + } + + private func store(_ response: CLILocalHTTPResponse, for key: String, ttl: TimeInterval, now: Date) { guard ttl > 0, response.status == .ok else { return } self.entries[key] = Entry(expiresAt: now.addingTimeInterval(ttl), response: response) } @@ -86,6 +190,7 @@ private actor CLIServeResponseCache { private enum CLIServeArgumentError: LocalizedError { case invalidPort case invalidRefreshInterval + case invalidRequestTimeout case invalidProvider(String) var errorDescription: String? { @@ -94,6 +199,8 @@ private enum CLIServeArgumentError: LocalizedError { "--port must be between 1 and 65535." case .invalidRefreshInterval: "--refresh-interval must be zero or greater." + case .invalidRequestTimeout: + "--request-timeout must be zero or greater." case let .invalidProvider(provider): "Unknown provider '\(provider)'." } @@ -101,10 +208,13 @@ private enum CLIServeArgumentError: LocalizedError { } extension CodexBarCLI { + static let defaultServeRequestTimeout: TimeInterval = 30 + static func runServe(_ values: ParsedValues) async { let output = CLIOutputPreferences(format: .json, jsonOnly: true, pretty: false) let port = Self.decodeServePort(from: values) let refreshInterval = Self.decodeServeRefreshInterval(from: values) + let requestTimeout = Self.decodeServeRequestTimeout(from: values) guard let port else { Self.exit( @@ -122,6 +232,14 @@ extension CodexBarCLI { kind: .args) } + guard let requestTimeout else { + Self.exit( + code: .failure, + message: CLIServeArgumentError.invalidRequestTimeout.localizedDescription, + output: output, + kind: .args) + } + let config = Self.loadConfig(output: output) let cache = CLIServeResponseCache() let server = CLILocalHTTPServer(host: "127.0.0.1", port: port) { request in @@ -129,7 +247,8 @@ extension CodexBarCLI { request, config: config, cache: cache, - refreshInterval: refreshInterval) + refreshInterval: refreshInterval, + requestTimeout: requestTimeout) } do { @@ -167,11 +286,25 @@ extension CodexBarCLI { return parsed } + static func decodeServeRequestTimeout(from values: ParsedValues) -> TimeInterval? { + let raw = values.options["requestTimeout"]?.last + let parsed: Double + if let raw { + guard let value = Double(raw) else { return nil } + parsed = value + } else { + parsed = Self.defaultServeRequestTimeout + } + guard parsed >= 0 else { return nil } + return parsed + } + private static func handleServeRequest( _ request: CLILocalHTTPRequest, config: CodexBarConfig, cache: CLIServeResponseCache, - refreshInterval: TimeInterval) async -> CLILocalHTTPResponse + refreshInterval: TimeInterval, + requestTimeout: TimeInterval) async -> CLILocalHTTPResponse { let route: CLIServeRoute do { @@ -192,7 +325,8 @@ extension CodexBarCLI { return await Self.cachedServeResponse( key: "usage:\(provider ?? "")", cache: cache, - refreshInterval: refreshInterval) + refreshInterval: refreshInterval, + requestTimeout: requestTimeout) { await Self.serveUsage(provider: provider, config: config) } @@ -200,29 +334,69 @@ extension CodexBarCLI { return await Self.cachedServeResponse( key: "cost:\(provider ?? "")", cache: cache, - refreshInterval: refreshInterval) + refreshInterval: refreshInterval, + requestTimeout: requestTimeout) { await Self.serveCost(provider: provider, config: config) } } } - private static func cachedServeResponse( + static func cachedServeResponse( key: String, cache: CLIServeResponseCache, refreshInterval: TimeInterval, - makeResponse: () async -> CLILocalHTTPResponse) async -> CLILocalHTTPResponse + requestTimeout: TimeInterval = CodexBarCLI.defaultServeRequestTimeout, + makeResponse: @Sendable @escaping () async -> CLILocalHTTPResponse) async -> CLILocalHTTPResponse { - let now = Date() - if let cached = await cache.response(for: key, now: now) { - return cached + switch await cache.responseOrStartFetch(for: key, now: Date()) { + case let .response(response): + return response + case .miss: + let response = await Self.serveResponseWithDeadline(seconds: requestTimeout) { + await makeResponse() + } + await cache.completeFetch( + response, + for: key, + ttl: refreshInterval, + now: Date(), + shouldCache: Self.shouldCacheServeResponse(response)) + return response } + } - let response = await makeResponse() - if Self.shouldCacheServeResponse(response) { - await cache.store(response, for: key, ttl: refreshInterval, now: now) + private static func serveResponseWithDeadline( + seconds timeout: TimeInterval, + makeResponse: @Sendable @escaping () async -> CLILocalHTTPResponse) async -> CLILocalHTTPResponse + { + let clampedTimeout = min(max(timeout, 0), 86400) + guard clampedTimeout > 0 else { + return await makeResponse() + } + let nanoseconds = max(1, UInt64((clampedTimeout * 1_000_000_000).rounded(.up))) + + return await withCheckedContinuation { continuation in + let state = CLIServeDeadlineState(continuation: continuation) + let workTask = Task { + let response = await makeResponse() + state.finish(response, cancelWork: false, cancelTimeout: true) + } + state.setWorkTask(workTask) + + let timeoutTask = Task { + do { + try await Task.sleep(nanoseconds: nanoseconds) + } catch { + return + } + state.finish( + Self.serveError(status: .gatewayTimeout, message: "request timed out"), + cancelWork: true, + cancelTimeout: false) + } + state.setTimeoutTask(timeoutTask) } - return response } static func shouldCacheServeResponse(_ response: CLILocalHTTPResponse) -> Bool { diff --git a/Sources/CodexBarCLI/CLIUsageCommand.swift b/Sources/CodexBarCLI/CLIUsageCommand.swift index ddbfefaa..42506cef 100644 --- a/Sources/CodexBarCLI/CLIUsageCommand.swift +++ b/Sources/CodexBarCLI/CLIUsageCommand.swift @@ -288,7 +288,10 @@ extension CodexBarCLI { settings: settings, fetcher: tokenContext.fetcher(base: command.fetcher, provider: provider, env: env), claudeFetcher: command.claudeFetcher, - browserDetection: command.browserDetection) + browserDetection: command.browserDetection, + selectedTokenAccountID: account?.id, + tokenAccountTokenUpdater: tokenContext.tokenUpdater(for: account), + providerManualTokenUpdater: tokenContext.manualTokenUpdater()) let outcome = await Self.fetchProviderUsage( provider: provider, context: fetchContext) diff --git a/Sources/CodexBarCLI/DiagnoseOptions.swift b/Sources/CodexBarCLI/DiagnoseOptions.swift new file mode 100644 index 00000000..6a1653da --- /dev/null +++ b/Sources/CodexBarCLI/DiagnoseOptions.swift @@ -0,0 +1,23 @@ +import CodexBarCore +import Commander +import Foundation + +struct DiagnoseOptions: CommanderParsable { + @Flag(names: [.short("v"), .long("verbose")], help: "Enable verbose logging") + var verbose: Bool = false + + @Flag(name: .long("json-output"), help: "Emit machine-readable logs") + var jsonOutput: Bool = false + + @Option(name: .long("log-level"), help: "Set log level (trace|verbose|debug|info|warning|error|critical)") + var logLevel: String? + + @Option(name: .long("provider"), help: ProviderHelp.optionHelp) + var provider: String? + + @Option(name: .long("format"), help: "Output format: json") + var format: String? + + @Flag(name: .long("pretty"), help: "Pretty-print JSON output") + var pretty: Bool = false +} diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index a4fbfc71..84dad764 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -239,7 +239,7 @@ struct TokenAccountCLIContext { return self.makeSnapshot( stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( cookieSource: cookieSource, - manualToken: cookieHeader ?? "", + manualToken: self.stepfunManualToken(account: account, config: config), username: config?.sanitizedAPIKey ?? "", password: "")) default: @@ -346,6 +346,68 @@ struct TokenAccountCLIContext { return env } + func tokenUpdater(for account: ProviderTokenAccount?) -> ProviderFetchContext.TokenAccountTokenUpdater? { + guard let account else { return nil } + return { provider, accountID, token in + guard accountID == account.id else { return } + try? Self.updateStoredTokenAccount(provider: provider, accountID: accountID, token: token) + } + } + + func manualTokenUpdater() -> ProviderFetchContext.ProviderManualTokenUpdater { + { provider, token in + try? Self.updateStoredManualToken(provider: provider, token: token) + } + } + + private static func updateStoredManualToken(provider: UsageProvider, token: String) throws { + guard provider == .stepfun else { return } + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let store = CodexBarConfigStore() + var config = try store.load() ?? .makeDefault() + var providerConfig = config.providerConfig(for: provider) ?? ProviderConfig(id: provider) + providerConfig.region = trimmed + config.setProviderConfig(providerConfig) + try store.save(config) + } + + private static func updateStoredTokenAccount( + provider: UsageProvider, + accountID: UUID, + token: String) throws + { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let store = CodexBarConfigStore() + guard var config = try store.load() else { return } + guard var providerConfig = config.providerConfig(for: provider), + let data = providerConfig.tokenAccounts, + let index = data.accounts.firstIndex(where: { $0.id == accountID }) + else { + return + } + + let existing = data.accounts[index] + var accounts = data.accounts + accounts[index] = ProviderTokenAccount( + id: existing.id, + label: existing.label, + token: trimmed, + addedAt: existing.addedAt, + lastUsed: existing.lastUsed, + externalIdentifier: existing.externalIdentifier, + organizationID: existing.organizationID) + providerConfig.tokenAccounts = ProviderTokenAccountData( + version: data.version, + accounts: accounts, + activeIndex: data.clampedActiveIndex()) + config.setProviderConfig(providerConfig) + try store.save(config) + } + func fetcher(base: UsageFetcher, provider: UsageProvider, env: [String: String]) -> UsageFetcher { guard provider == .codex else { return base } return UsageFetcher(environment: env) @@ -487,12 +549,24 @@ struct TokenAccountCLIContext { return .manual } if let override = config?.cookieSource { return override } + if provider == .stepfun, config?.sanitizedRegion != nil { + return .manual + } if config?.sanitizedCookieHeader != nil { return .manual } return .auto } + private func stepfunManualToken(account: ProviderTokenAccount?, config: ProviderConfig?) -> String { + if let account, + let support = TokenAccountSupportCatalog.support(for: .stepfun) + { + return TokenAccountSupportCatalog.normalizedCookieHeader(account.token, support: support) + } + return config?.sanitizedRegion ?? config?.sanitizedCookieHeader ?? "" + } + private func resolveZaiRegion(_ config: ProviderConfig?) -> ZaiAPIRegion { guard let raw = config?.region?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty diff --git a/Sources/CodexBarCore/Config/CodexBarConfig.swift b/Sources/CodexBarCore/Config/CodexBarConfig.swift index 57ef8e22..4c1910bd 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfig.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfig.swift @@ -89,6 +89,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { public var quotaWarnings: QuotaWarningConfig? public var kiloKnownOrganizations: [KiloOrganization]? public var kiloEnabledOrganizationIDs: [String]? + public var awsProfile: String? + public var awsAuthMode: String? public init( id: UsageProvider, @@ -106,7 +108,9 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { codexActiveSource: CodexActiveSource? = nil, quotaWarnings: QuotaWarningConfig? = nil, kiloKnownOrganizations: [KiloOrganization]? = nil, - kiloEnabledOrganizationIDs: [String]? = nil) + kiloEnabledOrganizationIDs: [String]? = nil, + awsProfile: String? = nil, + awsAuthMode: String? = nil) { self.id = id self.enabled = enabled @@ -124,6 +128,8 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { self.quotaWarnings = quotaWarnings self.kiloKnownOrganizations = kiloKnownOrganizations self.kiloEnabledOrganizationIDs = kiloEnabledOrganizationIDs + self.awsProfile = awsProfile + self.awsAuthMode = awsAuthMode } public var sanitizedAPIKey: String? { @@ -150,6 +156,14 @@ public struct ProviderConfig: Codable, Sendable, Identifiable { Self.clean(self.enterpriseHost) } + public var sanitizedAWSProfile: String? { + Self.clean(self.awsProfile) + } + + public var sanitizedAWSAuthMode: String? { + Self.clean(self.awsAuthMode) + } + private static func clean(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift index e1ca7bae..dd692011 100644 --- a/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift +++ b/Sources/CodexBarCore/Config/CodexBarConfigValidation.swift @@ -28,6 +28,14 @@ public struct CodexBarConfigIssue: Codable, Sendable, Equatable { } public enum CodexBarConfigValidator { + private static let workspaceIDProviders: [UsageProvider] = [ + .azureopenai, + .openai, + .opencode, + .opencodego, + .deepgram, + ] + public static func validate(_ config: CodexBarConfig) -> [CodexBarConfigIssue] { var issues: [CodexBarConfigIssue] = [] @@ -138,8 +146,7 @@ public enum CodexBarConfigValidator { provider: provider, field: "workspaceID", code: "workspace_unused", - message: "workspaceID is set but only azureopenai, opencode, opencodego, and deepgram support " + - "workspaceID.")) + message: "workspaceID is set but only \(self.workspaceIDProviderList) support workspaceID.")) } if let enterpriseHost = entry.enterpriseHost, @@ -183,12 +190,18 @@ public enum CodexBarConfigValidator { } private static func providerSupportsWorkspaceID(_ provider: UsageProvider) -> Bool { - switch provider { - case .azureopenai, .opencode, .opencodego, .deepgram: - true - default: - false - } + self.workspaceIDProviders.contains(provider) + } + + private static var workspaceIDProviderList: String { + self.formattedProviderList(self.workspaceIDProviders) + } + + private static func formattedProviderList(_ providers: [UsageProvider]) -> String { + let names = providers.map(\.rawValue) + guard let last = names.last else { return "" } + guard names.count > 1 else { return last } + return "\(names.dropLast().joined(separator: ", ")), and \(last)" } private static func providerSupportsEnterpriseHost(_ provider: UsageProvider) -> Bool { diff --git a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift index 49dcbd0b..896a47ef 100644 --- a/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift +++ b/Sources/CodexBarCore/Config/ProviderConfigEnvironment.swift @@ -6,6 +6,9 @@ public enum ProviderConfigEnvironment { provider: UsageProvider, config: ProviderConfig?) -> [String: String] { + if provider == .openai { + return self.applyOpenAIOverrides(base: base, config: config) + } if provider == .bedrock { return self.applyBedrockOverrides(base: base, config: config) } @@ -110,21 +113,75 @@ public enum ProviderConfigEnvironment { } } + private static func applyOpenAIOverrides( + base: [String: String], + config: ProviderConfig?) -> [String: String] + { + guard let config else { return base } + var env = base + if let apiKey = config.sanitizedAPIKey { + env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] = apiKey + } + if let projectID = config.sanitizedWorkspaceID { + env[OpenAIAPISettingsReader.projectIDEnvironmentKey] = projectID + } + return env + } + private static func applyBedrockOverrides( base: [String: String], config: ProviderConfig?) -> [String: String] { guard let config else { return base } var env = base - if let accessKeyID = config.sanitizedAPIKey { - env[BedrockSettingsReader.accessKeyIDKey] = accessKeyID + + // Only project an explicit auth-mode selection. When the config does not + // specify one, leave the base environment untouched so an env-driven setup + // (AWS_PROFILE or CODEXBAR_BEDROCK_AUTH_MODE from the launch environment) is + // still inferred by BedrockSettingsReader instead of being forced to `keys`. + let configMode = config.sanitizedAWSAuthMode.flatMap(BedrockAuthMode.init(rawValue:)) + if let configMode { + env[BedrockSettingsReader.authModeKey] = configMode.rawValue + } + let baseMode = BedrockSettingsReader + .cleaned(base[BedrockSettingsReader.authModeKey]) + .flatMap { BedrockAuthMode(rawValue: $0.lowercased()) } + + let mergedAccessKey = config.sanitizedAPIKey ?? BedrockSettingsReader.accessKeyID(environment: base) + let mergedSecretKey = config.sanitizedSecretKey ?? BedrockSettingsReader.secretAccessKey(environment: base) + let hasMergedStaticKeys = mergedAccessKey != nil && mergedSecretKey != nil + let effectiveMode: BedrockAuthMode = if let configMode { + configMode + } else if let baseMode { + baseMode + } else if hasMergedStaticKeys { + // Upgrade path: a config saved before auth modes existed keeps using + // static credentials (including env+config layering) even if AWS_PROFILE + // is present in the base environment, so existing users are never + // silently switched to a profile/account. + .keys + } else { + BedrockSettingsReader.authMode(environment: base) } - if let secretAccessKey = config.sanitizedSecretKey { - env[BedrockSettingsReader.secretAccessKeyKey] = secretAccessKey + + switch effectiveMode { + case .profile: + if let profile = config.sanitizedAWSProfile { + env[BedrockSettingsReader.profileKey] = profile + } + case .keys: + if let accessKeyID = config.sanitizedAPIKey { + env[BedrockSettingsReader.accessKeyIDKey] = accessKeyID + } + if let secretAccessKey = config.sanitizedSecretKey { + env[BedrockSettingsReader.secretAccessKeyKey] = secretAccessKey + } } + if let region = config.sanitizedRegion { env[BedrockSettingsReader.regionKeys[0]] = region } + return env } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index f9413e65..2611e81a 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -18,7 +18,27 @@ public enum CostUsageError: LocalizedError, Sendable { } public struct CostUsageFetcher: Sendable { - public init() {} + private let scannerOptions: CostUsageScanner.Options? + + public init(cacheRoot: URL? = nil) { + self.scannerOptions = cacheRoot.map { CostUsageScanner.Options(cacheRoot: $0) } + } + + init(scannerOptions: CostUsageScanner.Options) { + self.scannerOptions = scannerOptions + } + + public func loadCachedCodexTokenSnapshot( + now: Date = Date(), + codexHomePath: String? = nil, + historyDays: Int = 30) async -> CostUsageTokenSnapshot? + { + await Self.loadCachedCodexTokenSnapshot( + now: now, + codexHomePath: codexHomePath, + historyDays: historyDays, + scannerOptions: self.scannerOptionsOverride()) + } public func loadTokenSnapshot( provider: UsageProvider, @@ -38,7 +58,12 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, codexHomePath: codexHomePath, historyDays: historyDays, - refreshPricingInBackground: refreshPricingInBackground) + refreshPricingInBackground: refreshPricingInBackground, + scannerOptions: self.scannerOptionsOverride()) + } + + private func scannerOptionsOverride() -> CostUsageScanner.Options? { + self.scannerOptions } static func loadTokenSnapshot( @@ -98,12 +123,18 @@ public struct CostUsageFetcher: Sendable { if forceRefresh { options.refreshMinIntervalSeconds = 0 } - var daily = CostUsageScanner.loadDailyReport( + let checkCancellation: CostUsageScanner.CancellationCheck = { + try Task.checkCancellation() + } + try Task.checkCancellation() + var daily = try CostUsageScanner.loadDailyReportCancellable( provider: provider, since: since, until: until, now: now, - options: options) + options: options, + checkCancellation: checkCancellation) + try Task.checkCancellation() if provider == .vertexai, !allowVertexClaudeFallback, @@ -112,12 +143,14 @@ public struct CostUsageFetcher: Sendable { { var fallback = options fallback.claudeLogProviderFilter = .all - daily = CostUsageScanner.loadDailyReport( + daily = try CostUsageScanner.loadDailyReportCancellable( provider: provider, since: since, until: until, now: now, - options: fallback) + options: fallback, + checkCancellation: checkCancellation) + try Task.checkCancellation() } if provider == .codex || provider == .claude { @@ -128,34 +161,80 @@ public struct CostUsageFetcher: Sendable { if forceRefresh { piOptions.refreshMinIntervalSeconds = 0 } - let piReport = PiSessionCostScanner.loadDailyReport( + let piReport = try PiSessionCostScanner.loadDailyReportCancellable( provider: provider, since: since, until: until, now: now, - options: piOptions) + options: piOptions, + checkCancellation: checkCancellation) + try Task.checkCancellation() daily = CostUsageDailyReport.merged([daily, piReport]) } return Self.tokenSnapshot(from: daily, now: now, historyDays: clampedHistoryDays) } + static func loadCachedCodexTokenSnapshot( + now: Date = Date(), + codexHomePath: String? = nil, + historyDays: Int = 30, + scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CostUsageTokenSnapshot? + { + if let codexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), + !codexHomePath.isEmpty + { + return nil + } + + return await Task.detached(priority: .utility) { + let clampedHistoryDays = max(1, min(365, historyDays)) + let until = now + let since = Calendar.current.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now + let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) + let options = overrideScannerOptions ?? CostUsageScanner.Options() + let cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) + var reports: [CostUsageDailyReport] = [] + + if !cache.days.isEmpty, + cache.roots == CostUsageScanner.codexRootsFingerprint(options: options), + !CostUsageScanner.requestedWindowExpandsCache(range: range, cache: cache) + { + let daily = CostUsageScanner.buildCodexReportFromCache( + cache: cache, + range: range, + modelsDevCacheRoot: options.cacheRoot) + if !daily.data.isEmpty { + reports.append(daily) + } + } + + if let piDaily = PiSessionCostScanner.loadCachedDailyReport( + provider: .codex, + since: since, + until: until, + now: now, + cacheRoot: options.cacheRoot) + { + reports.append(piDaily) + } + + guard !reports.isEmpty else { return nil } + return Self.tokenSnapshot( + from: CostUsageDailyReport.merged(reports), + now: now, + historyDays: clampedHistoryDays) + }.value + } + private static func loadBedrockDailyReport( environment: [String: String], since: Date, until: Date) async throws -> CostUsageDailyReport { - guard let accessKeyID = BedrockSettingsReader.accessKeyID(environment: environment), - let secretAccessKey = BedrockSettingsReader.secretAccessKey(environment: environment) - else { - throw BedrockUsageError.missingCredentials - } - let credentials = BedrockAWSSigner.Credentials( - accessKeyID: accessKeyID, - secretAccessKey: secretAccessKey, - sessionToken: BedrockSettingsReader.sessionToken(environment: environment)) + let resolved = try await BedrockCredentialResolver.resolve(environment: environment) return try await BedrockUsageFetcher.fetchDailyReport( - credentials: credentials, + credentials: resolved.credentials, since: since, until: until, environment: environment) diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 07214936..0a891b34 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -3,26 +3,40 @@ import Foundation public struct CostUsageTokenSnapshot: Sendable, Equatable { public let sessionTokens: Int? public let sessionCostUSD: Double? + public let sessionRequests: Int? public let last30DaysTokens: Int? public let last30DaysCostUSD: Double? + public let last30DaysRequests: Int? + public let currencyCode: String public let historyDays: Int + public let historyLabel: String? public let daily: [CostUsageDailyReport.Entry] public let updatedAt: Date public init( sessionTokens: Int?, sessionCostUSD: Double?, + sessionRequests: Int? = nil, last30DaysTokens: Int?, last30DaysCostUSD: Double?, + last30DaysRequests: Int? = nil, + currencyCode: String = "USD", historyDays: Int = 30, + historyLabel: String? = nil, daily: [CostUsageDailyReport.Entry], updatedAt: Date) { self.sessionTokens = sessionTokens self.sessionCostUSD = sessionCostUSD + self.sessionRequests = sessionRequests self.last30DaysTokens = last30DaysTokens self.last30DaysCostUSD = last30DaysCostUSD + self.last30DaysRequests = last30DaysRequests + self.currencyCode = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "USD" + : currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() self.historyDays = historyDays + self.historyLabel = historyLabel self.daily = daily self.updatedAt = updatedAt } @@ -33,6 +47,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let modelName: String public let costUSD: Double? public let totalTokens: Int? + public let requestCount: Int? public let standardCostUSD: Double? public let priorityCostUSD: Double? public let standardTokens: Int? @@ -43,6 +58,8 @@ public struct CostUsageDailyReport: Sendable, Decodable { case costUSD case cost case totalTokens + case requestCount + case requests case standardCostUSD case priorityCostUSD case standardTokens @@ -56,6 +73,9 @@ public struct CostUsageDailyReport: Sendable, Decodable { try container.decodeIfPresent(Double.self, forKey: .costUSD) ?? container.decodeIfPresent(Double.self, forKey: .cost) self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) + self.requestCount = + try container.decodeIfPresent(Int.self, forKey: .requestCount) + ?? container.decodeIfPresent(Int.self, forKey: .requests) self.standardCostUSD = try container.decodeIfPresent(Double.self, forKey: .standardCostUSD) self.priorityCostUSD = try container.decodeIfPresent(Double.self, forKey: .priorityCostUSD) self.standardTokens = try container.decodeIfPresent(Int.self, forKey: .standardTokens) @@ -66,6 +86,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { modelName: String, costUSD: Double?, totalTokens: Int? = nil, + requestCount: Int? = nil, standardCostUSD: Double? = nil, priorityCostUSD: Double? = nil, standardTokens: Int? = nil, @@ -74,6 +95,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.modelName = modelName self.costUSD = costUSD self.totalTokens = totalTokens + self.requestCount = requestCount self.standardCostUSD = standardCostUSD self.priorityCostUSD = priorityCostUSD self.standardTokens = standardTokens @@ -88,6 +110,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { public let cacheCreationTokens: Int? public let outputTokens: Int? public let totalTokens: Int? + public let requestCount: Int? public let costUSD: Double? public let modelsUsed: [String]? public let modelBreakdowns: [ModelBreakdown]? @@ -101,6 +124,8 @@ public struct CostUsageDailyReport: Sendable, Decodable { case cacheCreationInputTokens case outputTokens case totalTokens + case requestCount + case requests case costUSD case totalCost case modelsUsed @@ -120,6 +145,9 @@ public struct CostUsageDailyReport: Sendable, Decodable { ?? container.decodeIfPresent(Int.self, forKey: .cacheCreationInputTokens) self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) self.totalTokens = try container.decodeIfPresent(Int.self, forKey: .totalTokens) + self.requestCount = + try container.decodeIfPresent(Int.self, forKey: .requestCount) + ?? container.decodeIfPresent(Int.self, forKey: .requests) self.costUSD = try container.decodeIfPresent(Double.self, forKey: .costUSD) ?? container.decodeIfPresent(Double.self, forKey: .totalCost) @@ -134,6 +162,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { cacheReadTokens: Int? = nil, cacheCreationTokens: Int? = nil, totalTokens: Int?, + requestCount: Int? = nil, costUSD: Double?, modelsUsed: [String]?, modelBreakdowns: [ModelBreakdown]?) @@ -144,6 +173,7 @@ public struct CostUsageDailyReport: Sendable, Decodable { self.cacheReadTokens = cacheReadTokens self.cacheCreationTokens = cacheCreationTokens self.totalTokens = totalTokens + self.requestCount = requestCount self.costUSD = costUSD self.modelsUsed = modelsUsed self.modelBreakdowns = modelBreakdowns diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 0f06ee79..751aae1a 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "f9f141ee9b76d38b" + static let value = "13190e0c386e80f6" } diff --git a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift index 4febdad9..b2278c25 100644 --- a/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift +++ b/Sources/CodexBarCore/Host/Process/SubprocessRunner.swift @@ -158,6 +158,8 @@ public enum SubprocessRunner { arguments: [String], environment: [String: String], timeout: TimeInterval, + standardInput: Any? = nil, + currentDirectoryURL: URL? = nil, label: String) async throws -> SubprocessResult { guard FileManager.default.isExecutableFile(atPath: binary) else { @@ -174,12 +176,13 @@ public enum SubprocessRunner { process.executableURL = URL(fileURLWithPath: binary) process.arguments = arguments process.environment = environment + process.currentDirectoryURL = currentDirectoryURL let stdoutPipe = Pipe() let stderrPipe = Pipe() process.standardOutput = stdoutPipe process.standardError = stderrPipe - process.standardInput = nil + process.standardInput = standardInput let termination = ProcessTermination() process.terminationHandler = { process in diff --git a/Sources/CodexBarCore/KeychainCacheStore.swift b/Sources/CodexBarCore/KeychainCacheStore.swift index 405199c0..a9a935b4 100644 --- a/Sources/CodexBarCore/KeychainCacheStore.swift +++ b/Sources/CodexBarCore/KeychainCacheStore.swift @@ -101,11 +101,12 @@ public enum KeychainCacheStore { return } - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, ] + KeychainNoUIQuery.apply(to: &query) let updateStatus = SecItemUpdate( query as CFDictionary, @@ -140,20 +141,16 @@ public enum KeychainCacheStore { } guard self.canUseRealKeychain else { return false } #if os(macOS) - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.serviceName, kSecAttrAccount as String: key.account, ] - let status = SecItemDelete(query as CFDictionary) - if status == errSecSuccess { - return true - } - if status != errSecItemNotFound { - self.log.error("Keychain cache delete failed (\(key.account)): \(status)") - } - #endif + KeychainNoUIQuery.apply(to: &query) + return self.clearResultForKeychainDeleteStatus(SecItemDelete(query as CFDictionary), key: key) + #else return false + #endif } public static func keys(category: String) -> [Key] { @@ -232,6 +229,10 @@ public enum KeychainCacheStore { self.canUseRealKeychain } + static var canEnumerateOrDeleteRealKeychainForTesting: Bool { + self.canUseRealKeychain + } + #if DEBUG && os(macOS) public static func withLoadFailureStatusOverrideForTesting( _ status: OSStatus?, @@ -314,6 +315,21 @@ public enum KeychainCacheStore { } } + static func clearResultForKeychainDeleteStatus(_ status: OSStatus, key: Key) -> Bool { + switch status { + case errSecSuccess: + return true + case errSecItemNotFound: + return false + case errSecInteractionNotAllowed: + self.log.info("Keychain cache delete temporarily unavailable (\(key.account))") + return false + default: + self.log.error("Keychain cache delete failed (\(key.account)): \(status)") + return false + } + } + static func trustedApplicationPathsForCacheAccess( bundleURL: URL = Bundle.main.bundleURL, executableURL: URL? = Bundle.main.executableURL, diff --git a/Sources/CodexBarCore/Logging/CodexBarLog.swift b/Sources/CodexBarCore/Logging/CodexBarLog.swift index 2f9db069..7a64eb32 100644 --- a/Sources/CodexBarCore/Logging/CodexBarLog.swift +++ b/Sources/CodexBarCore/Logging/CodexBarLog.swift @@ -4,6 +4,7 @@ import Logging public enum CodexBarLog { public enum Destination: Sendable { case stderr + case discard case oslog(subsystem: String) } @@ -85,6 +86,8 @@ public enum CodexBarLog { case .stderr: if config.json { return JSONStderrLogHandler(label: label) } return StreamLogHandler.standardError(label: label) + case .discard: + return DiscardLogHandler() case let .oslog(subsystem): #if canImport(os) return OSLogLogHandler(label: label, subsystem: subsystem) @@ -154,6 +157,18 @@ public enum CodexBarLog { } } +private struct DiscardLogHandler: LogHandler { + var metadata: Logger.Metadata = [:] + var logLevel: Logger.Level = .critical + + subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { + get { self.metadata[metadataKey] } + set { self.metadata[metadataKey] = newValue } + } + + func log(event _: LogEvent) {} +} + public struct CodexBarLogger: Sendable { private let logFn: @Sendable (CodexBarLog.Level, String, [String: String]?) -> Void diff --git a/Sources/CodexBarCore/Logging/LogRedactor.swift b/Sources/CodexBarCore/Logging/LogRedactor.swift index 02cf42c4..d664fd7f 100644 --- a/Sources/CodexBarCore/Logging/LogRedactor.swift +++ b/Sources/CodexBarCore/Logging/LogRedactor.swift @@ -18,13 +18,23 @@ public enum LogRedactor { pattern: #"(?i)(authorization\s*:\s*)([^\r\n]+)"#) private static let bearerRegex = Self.makeRegex( pattern: #"(?i)\bbearer\s+[a-z0-9._\-]+=*\b"#) + private static let minimaxCodingPlanTokenRegex = Self.makeRegex( + pattern: #"sk-cp-[^\s"'`;,)>\]]+"#) + private static let minimaxApiTokenRegex = Self.makeRegex( + pattern: #"sk-api-[^\s"'`;,)>\]]+"#) public static func redact(_ text: String) -> String { var output = text + // Email is broad and safe first output = self.replace(self.emailRegex, in: output, with: "") + // MiniMax tokens before broader rules catch them + output = self.replace(self.minimaxCodingPlanTokenRegex, in: output, with: "") + output = self.replace(self.minimaxApiTokenRegex, in: output, with: "") + // Bearer catches "bearer " before authorization wraps it + output = self.replace(self.bearerRegex, in: output, with: "Bearer ") + // Authorization catches the rest (already-redacted content) output = self.replace(self.cookieHeaderRegex, in: output, with: "$1") output = self.replace(self.authorizationRegex, in: output, with: "$1") - output = self.replace(self.bearerRegex, in: output, with: "Bearer ") return output } diff --git a/Sources/CodexBarCore/OpenAIDashboardModels.swift b/Sources/CodexBarCore/OpenAIDashboardModels.swift index 7d776a24..266e548a 100644 --- a/Sources/CodexBarCore/OpenAIDashboardModels.swift +++ b/Sources/CodexBarCore/OpenAIDashboardModels.swift @@ -13,6 +13,9 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { public let creditsPurchaseURL: String? public let primaryLimit: RateWindow? public let secondaryLimit: RateWindow? + /// Named model-specific limits (e.g. Codex Spark) decoded from the dashboard + /// `wham/usage` response's `additional_rate_limits` array. + public let extraRateWindows: [NamedRateWindow]? public let creditsRemaining: Double? public let accountPlan: String? public let updatedAt: Date @@ -27,6 +30,7 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { creditsPurchaseURL: String?, primaryLimit: RateWindow? = nil, secondaryLimit: RateWindow? = nil, + extraRateWindows: [NamedRateWindow]? = nil, creditsRemaining: Double? = nil, accountPlan: String? = nil, updatedAt: Date) @@ -40,6 +44,7 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { self.creditsPurchaseURL = creditsPurchaseURL self.primaryLimit = primaryLimit self.secondaryLimit = secondaryLimit + self.extraRateWindows = extraRateWindows self.creditsRemaining = creditsRemaining self.accountPlan = accountPlan self.updatedAt = updatedAt @@ -55,6 +60,7 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { case creditsPurchaseURL case primaryLimit case secondaryLimit + case extraRateWindows case creditsRemaining case accountPlan case updatedAt @@ -80,6 +86,10 @@ public struct OpenAIDashboardSnapshot: Codable, Equatable, Sendable { self.creditsPurchaseURL = try container.decodeIfPresent(String.self, forKey: .creditsPurchaseURL) self.primaryLimit = try container.decodeIfPresent(RateWindow.self, forKey: .primaryLimit) self.secondaryLimit = try container.decodeIfPresent(RateWindow.self, forKey: .secondaryLimit) + // Backward-compatible: older cached snapshots simply lack the key and decode to nil. + self.extraRateWindows = try container.decodeIfPresent( + [NamedRateWindow].self, + forKey: .extraRateWindows) self.creditsRemaining = try container.decodeIfPresent(Double.self, forKey: .creditsRemaining) self.accountPlan = try container.decodeIfPresent(String.self, forKey: .accountPlan) self.updatedAt = try container.decode(Date.self, forKey: .updatedAt) diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift index 93caf063..bf742735 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardFetcher.swift @@ -53,6 +53,7 @@ public struct OpenAIDashboardFetcher { let breakdown: [OpenAIDashboardDailyBreakdown] let usageBreakdown: [OpenAIDashboardDailyBreakdown] let rateLimits: (primary: RateWindow?, secondary: RateWindow?) + let extraRateWindows: [NamedRateWindow] let creditsRemaining: Double? let accountPlan: String? } @@ -65,6 +66,7 @@ public struct OpenAIDashboardFetcher { let breakdown: [OpenAIDashboardDailyBreakdown] let usageBreakdown: [OpenAIDashboardDailyBreakdown] let rateLimits: (primary: RateWindow?, secondary: RateWindow?) + let extraRateWindows: [NamedRateWindow] let creditsRemaining: Double? let accountPlan: String? let hasDashboardPageSignal: Bool @@ -84,6 +86,7 @@ public struct OpenAIDashboardFetcher { creditsPurchaseURL: components.scrape.creditsPurchaseURL, primaryLimit: components.rateLimits.primary, secondaryLimit: components.rateLimits.secondary, + extraRateWindows: components.extraRateWindows.isEmpty ? nil : components.extraRateWindows, creditsRemaining: components.creditsRemaining, accountPlan: components.accountPlan, updatedAt: Date()) @@ -122,6 +125,9 @@ public struct OpenAIDashboardFetcher { hasUsageLimits: hasUsageLimits, creditsRemaining: creditsRemaining) + // Codex `additional_rate_limits` (e.g. Codex Spark) only ship over the JSON usage API, so the + // dashboard HTML scrape never contributes here; we just forward what the apiData decoded. + let extraRateWindows = apiData?.extraRateWindows ?? [] return DashboardScrapeData( signedInEmail: self.firstNonEmpty(scrape.signedInEmail, verifiedSignedInEmail), codeReview: codeReview, @@ -130,6 +136,7 @@ public struct OpenAIDashboardFetcher { breakdown: breakdown, usageBreakdown: usageBreakdown, rateLimits: rateLimits, + extraRateWindows: extraRateWindows, creditsRemaining: creditsRemaining, accountPlan: accountPlan, hasDashboardPageSignal: self.hasAnyDashboardSignal( @@ -141,6 +148,7 @@ public struct OpenAIDashboardFetcher { struct DashboardAPIData { let primaryLimit: RateWindow? let secondaryLimit: RateWindow? + let extraRateWindows: [NamedRateWindow] let creditsRemaining: Double? let accountPlan: String? @@ -190,10 +198,16 @@ public struct OpenAIDashboardFetcher { return true } + private nonisolated static func sleepForDashboardPoll(_ duration: Duration) async throws { + try? await Task.sleep(for: duration) + try Task.checkCancellation() + } + public func loadLatestDashboard( accountEmail: String?, logger: ((String) -> Void)? = nil, debugDumpHTML: Bool = false, + allowNavigationTimeoutRetry: Bool = true, timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { let store = OpenAIDashboardWebsiteDataStore.store(forAccountEmail: accountEmail) @@ -201,6 +215,7 @@ public struct OpenAIDashboardFetcher { websiteDataStore: store, logger: logger, debugDumpHTML: debugDumpHTML, + allowNavigationTimeoutRetry: allowNavigationTimeoutRetry, timeout: timeout) } @@ -208,19 +223,21 @@ public struct OpenAIDashboardFetcher { websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)? = nil, debugDumpHTML: Bool = false, + allowNavigationTimeoutRetry: Bool = true, timeout: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { let deadline = Self.deadline(startingAt: Date(), timeout: timeout) let preflight = await Self.fetchDashboardAPIPreflight( websiteDataStore: websiteDataStore, logger: { logger?($0) }) - let apiData = preflight.apiData - let verifiedSignedInEmail = preflight.verifiedSignedInEmail + try Task.checkCancellation() + let (apiData, verifiedSignedInEmail) = (preflight.apiData, preflight.verifiedSignedInEmail) let lease = try await self.makeWebView( websiteDataStore: websiteDataStore, logger: logger, - timeout: Self.remainingTimeout(until: deadline)) + timeout: Self.remainingTimeout(until: deadline), + allowNavigationTimeoutRetry: allowNavigationTimeoutRetry) defer { lease.release() } let webView = lease.webView let log = lease.log @@ -236,6 +253,7 @@ public struct OpenAIDashboardFetcher { var lastUsageBreakdownError: String? var lastCreditsPurchaseURL: String? while Date() < deadline { + try Task.checkCancellation() let scrape = try await self.scrape(webView: webView) lastBody = scrape.bodyText ?? lastBody @@ -253,14 +271,14 @@ public struct OpenAIDashboardFetcher { } if scrape.workspacePicker { - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } // The page is a SPA and can land on ChatGPT UI or other routes; keep forcing the usage URL. if let href = scrape.href, !Self.isUsageRoute(href) { _ = webView.load(Self.usageURLRequest(url: self.usageURL)) - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } @@ -313,7 +331,7 @@ public struct OpenAIDashboardFetcher { "rows=\(scrape.rows.count)") if scrape.didScrollToCredits { log("scrollIntoView(Credits usage history) requested; waiting…") - try? await Task.sleep(for: .milliseconds(600)) + try await Self.sleepForDashboardPoll(.milliseconds(600)) continue } @@ -330,7 +348,7 @@ public struct OpenAIDashboardFetcher { creditsHeaderInViewport: scrape.creditsHeaderInViewport, didScrollToCredits: scrape.didScrollToCredits)) { - try? await Task.sleep(for: .milliseconds(400)) + try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } } @@ -342,7 +360,7 @@ public struct OpenAIDashboardFetcher { now: Date(), errorFirstSeenAt: usageBreakdownErrorFirstSeenAt)) { - try? await Task.sleep(for: .milliseconds(400)) + try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } @@ -351,7 +369,7 @@ public struct OpenAIDashboardFetcher { if codeReview != nil, usageBreakdown.isEmpty { let elapsed = Date().timeIntervalSince(codeReviewFirstSeenAt ?? Date()) if elapsed < 6 { - try? await Task.sleep(for: .milliseconds(400)) + try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } } @@ -364,11 +382,12 @@ public struct OpenAIDashboardFetcher { breakdown: dashboardData.breakdown, usageBreakdown: usageBreakdown, rateLimits: dashboardData.rateLimits, + extraRateWindows: dashboardData.extraRateWindows, creditsRemaining: dashboardData.creditsRemaining, accountPlan: dashboardData.accountPlan)) } - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) } if debugDumpHTML, let html = try? await self.fetchDebugHTML(webView: webView) { @@ -426,12 +445,13 @@ public struct OpenAIDashboardFetcher { var dashboardSignalSeenAt: Date? while Date() < deadline { + try Task.checkCancellation() let scrape = try await self.scrape(webView: webView) lastBody = scrape.bodyText ?? lastBody lastHref = scrape.href ?? lastHref if scrape.workspacePicker { - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } @@ -439,7 +459,7 @@ public struct OpenAIDashboardFetcher { usageRouteSeenAt = nil dashboardSignalSeenAt = nil _ = webView.load(Self.usageURLRequest(url: self.usageURL)) - try? await Task.sleep(for: .milliseconds(500)) + try await Self.sleepForDashboardPoll(.milliseconds(500)) continue } @@ -473,7 +493,7 @@ public struct OpenAIDashboardFetcher { signedInEmail: normalizedEmail, hasDashboardSignal: hasDashboardSignal)) { - try? await Task.sleep(for: .milliseconds(400)) + try await Self.sleepForDashboardPoll(.milliseconds(400)) continue } @@ -621,6 +641,7 @@ public struct OpenAIDashboardFetcher { websiteDataStore: WKWebsiteDataStore, logger: ((String) -> Void)?, timeout: TimeInterval, + allowNavigationTimeoutRetry: Bool = true, preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease { try await OpenAIDashboardWebViewCache.shared.acquire( @@ -628,6 +649,7 @@ public struct OpenAIDashboardFetcher { usageURL: self.usageURL, logger: logger, navigationTimeout: timeout, + allowTimeoutRetry: allowNavigationTimeoutRetry, preserveLoadedPageOnRelease: preserveLoadedPageOnRelease) } @@ -686,6 +708,8 @@ public struct OpenAIDashboardFetcher { DashboardAPIData( primaryLimit: self.rateWindow(from: response.rateLimit?.primaryWindow), secondaryLimit: self.rateWindow(from: response.rateLimit?.secondaryWindow), + extraRateWindows: CodexAdditionalRateLimitMapper.extraRateWindows( + from: response.additionalRateLimits), creditsRemaining: response.credits?.balance, accountPlan: response.planType?.rawValue) } @@ -962,6 +986,7 @@ public struct OpenAIDashboardFetcher { accountEmail _: String?, logger _: ((String) -> Void)? = nil, debugDumpHTML _: Bool = false, + allowNavigationTimeoutRetry _: Bool = true, timeout _: TimeInterval = 60) async throws -> OpenAIDashboardSnapshot { throw FetchError.noDashboardData(body: "OpenAI web dashboard fetch is only supported on macOS.") diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift index 59f3f1ef..71626669 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardNavigationDelegate.swift @@ -29,6 +29,10 @@ final class NavigationDelegate: NSObject, WKNavigationDelegate { DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) } + func cancel() { + self.completeOnce(.failure(CancellationError())) + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { self.completeOnce(.success(())) } diff --git a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift index 1e9a91d3..116731b3 100644 --- a/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift +++ b/Sources/CodexBarCore/OpenAIWeb/OpenAIDashboardWebViewCache.swift @@ -28,6 +28,34 @@ final class OpenAIDashboardWebViewCache { let preserveLoadedPageOnRelease: Bool } + @MainActor + private final class NavigationCancellationState { + private weak var webView: WKWebView? + private var delegate: NavigationDelegate? + private var isCancelled = false + + func install(webView: WKWebView, delegate: NavigationDelegate) { + self.webView = webView + self.delegate = delegate + if self.isCancelled { + self.cancel() + } + } + + func cancel() { + self.isCancelled = true + guard let webView, let delegate else { return } + delegate.cancel() + if webView.codexNavigationDelegate === delegate { + webView.stopLoading() + webView.navigationDelegate = nil + webView.codexNavigationDelegate = nil + } + self.delegate = nil + self.webView = nil + } + } + private final class Entry { let webView: WKWebView let host: OffscreenWebViewHost @@ -237,6 +265,7 @@ final class OpenAIDashboardWebViewCache { usageURL: URL, logger: ((String) -> Void)?, navigationTimeout: TimeInterval = 15, + allowTimeoutRetry: Bool = true, preserveLoadedPageOnRelease: Bool = false) async throws -> OpenAIDashboardWebViewLease { let deadline = Date().addingTimeInterval(max(navigationTimeout, 1)) @@ -246,7 +275,7 @@ final class OpenAIDashboardWebViewCache { logger: logger, deadline: deadline, options: .init( - allowTimeoutRetry: true, + allowTimeoutRetry: allowTimeoutRetry, preserveLoadedPageOnRelease: preserveLoadedPageOnRelease)) } @@ -497,14 +526,26 @@ final class OpenAIDashboardWebViewCache { Self.log.debug("OpenAI preserved page reset failed; reloading usage URL") } - try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in - let delegate = NavigationDelegate { result in - cont.resume(with: result) + try Task.checkCancellation() + let cancellationState = NavigationCancellationState() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + let delegate = NavigationDelegate { result in + cont.resume(with: result) + } + webView.navigationDelegate = delegate + webView.codexNavigationDelegate = delegate + cancellationState.install(webView: webView, delegate: delegate) + delegate.armTimeout(seconds: timeout) + _ = webView.load(OpenAIDashboardFetcher.usageURLRequest(url: usageURL)) + if Task.isCancelled { + cancellationState.cancel() + } + } + } onCancel: { + Task { @MainActor in + cancellationState.cancel() } - webView.navigationDelegate = delegate - webView.codexNavigationDelegate = delegate - delegate.armTimeout(seconds: timeout) - _ = webView.load(OpenAIDashboardFetcher.usageURLRequest(url: usageURL)) } } diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index 1c481f41..05f99cb4 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -166,6 +166,37 @@ public enum BinaryLocator { ] } + public static func resolveAWSBinary( + env: [String: String] = ProcessInfo.processInfo.environment, + loginPATH: [String]? = LoginShellPathCache.shared.current, + commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, + aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator + .resolveAlias, + fileManager: FileManager = .default, + home: String = NSHomeDirectory()) -> String? + { + self.resolveBinary( + name: "aws", + overrideKey: "AWS_CLI_PATH", + env: env, + loginPATH: loginPATH, + commandV: commandV, + aliasResolver: aliasResolver, + wellKnownPaths: self.awsWellKnownPaths(home: home), + fileManager: fileManager, + home: home) + } + + /// Well-known install locations for the AWS CLI v2 (`aws`). + /// Covers Homebrew (Apple Silicon + Intel) and the per-user pip/uv install path. + static func awsWellKnownPaths(home: String) -> [String] { + [ + "/opt/homebrew/bin/aws", + "/usr/local/bin/aws", + "\(home)/.local/bin/aws", + ] + } + public static func resolveAuggieBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, @@ -324,7 +355,7 @@ public enum CodexLaunchPreflight { return !hasQuarantine } - return !self.isExplicitlyBlockedAssessment(assessment) + return !self.isExplicitlyBlockedAssessment(assessment, path: native) } private static func nativeCodexExecutableCandidates(for path: String, fileManager: FileManager) -> [String] { @@ -417,14 +448,39 @@ public enum CodexLaunchPreflight { return String(data: data, encoding: .utf8) } - private static func isExplicitlyBlockedAssessment(_ assessment: String) -> Bool { - let lower = assessment.lowercased() - return lower.contains("rejected") || - lower.contains("denied") || + private static func isExplicitlyBlockedAssessment(_ assessment: String, path: String) -> Bool { + let lower = self.assessmentDiagnosticText(assessment, path: path).lowercased() + if lower.contains("denied") || lower.contains("cssmerr_tp_cert_revoked") || lower.contains("revoked") || lower.contains("malware") || lower.contains("quarantine") + { + return true + } + if lower.contains("rejected") { + return !lower.contains("code is valid but does not seem to be an app") + } + return false + } + + private static func assessmentDiagnosticText(_ assessment: String, path: String) -> String { + assessment + .split(whereSeparator: \.isNewline) + .enumerated() + .compactMap { offset, line -> String? in + var text = line.trimmingCharacters(in: .whitespacesAndNewlines) + if offset == 0, text.hasPrefix("\(path):") { + text = String(text.dropFirst(path.count + 1)) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + let lower = text.lowercased() + guard !lower.hasPrefix("source="), !lower.hasPrefix("origin=") else { + return nil + } + return text + } + .joined(separator: "\n") } #endif } diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index 2fcce0c8..a1aebbda 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -36,6 +36,13 @@ enum PiSessionCostScanner { let cacheRoot: URL? } + private struct ScanContext { + let range: CostUsageScanner.CostUsageDayRange + let forceRescan: Bool + let pricingContext: ModelsDevPricingContext + let checkCancellation: CostUsageScanner.CancellationCheck? + } + private static let costScale = 1_000_000_000.0 private static let maxLineBytes = 16 * 1024 * 1024 private static let maxSafeRoundedInt = Double(Int.max) - 1 @@ -46,6 +53,24 @@ enum PiSessionCostScanner { until: Date, now: Date = Date(), options: Options = Options()) -> CostUsageDailyReport + { + ( + try? self.loadDailyReportCancellable( + provider: provider, + since: since, + until: until, + now: now, + options: options, + checkCancellation: nil)) ?? CostUsageDailyReport(data: [], summary: nil) + } + + static func loadDailyReportCancellable( + provider: UsageProvider, + since: Date, + until: Date, + now: Date = Date(), + options: Options = Options(), + checkCancellation: CostUsageScanner.CancellationCheck?) throws -> CostUsageDailyReport { guard provider == .codex || provider == .claude else { return CostUsageDailyReport(data: [], summary: nil) @@ -66,19 +91,23 @@ enum PiSessionCostScanner { || nowMs - cache.lastScanUnixMs > refreshMs if shouldRefresh { + try checkCancellation?() let root = self.defaultPiSessionsRoot(options: options) let startCutoff = self.dateFromDayKey(range.scanSinceKey) ?? since let files = self.listPiSessionFiles(root: root, startCutoffLocal: startCutoff) let filePathsInScan = Set(files.map(\.path)) for fileURL in files { - self.scanPiSessionFile( + try self.scanPiSessionFile( fileURL: fileURL, - range: range, - forceRescan: options.forceRescan || windowExpanded, - pricingContext: pricingContext, - cache: &cache) + cache: &cache, + context: ScanContext( + range: range, + forceRescan: options.forceRescan || windowExpanded, + pricingContext: pricingContext, + checkCancellation: checkCancellation)) } + try checkCancellation?() for key in cache.files.keys where !filePathsInScan.contains(key) { if let old = cache.files[key] { @@ -93,6 +122,7 @@ enum PiSessionCostScanner { cache.scanSinceKey = range.scanSinceKey cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs + try checkCancellation?() PiSessionCostCacheIO.save(cache: cache, cacheRoot: options.cacheRoot) } @@ -103,6 +133,31 @@ enum PiSessionCostScanner { pricingContext: pricingContext) } + static func loadCachedDailyReport( + provider: UsageProvider, + since: Date, + until: Date, + now: Date = Date(), + cacheRoot: URL? = nil) -> CostUsageDailyReport? + { + guard provider == .codex || provider == .claude else { return nil } + + let range = CostUsageScanner.CostUsageDayRange(since: since, until: until) + let cache = PiSessionCostCacheIO.load(cacheRoot: cacheRoot) + guard !cache.daysByProvider.isEmpty else { return nil } + guard !self.requestedWindowExpandsCache(range: range, cache: cache) else { return nil } + + let pricingContext = ModelsDevPricingContext( + catalog: CostUsagePricing.modelsDevCatalog(now: now, cacheRoot: cacheRoot), + cacheRoot: cacheRoot) + let report = self.buildReport( + provider: provider, + cache: cache, + range: range, + pricingContext: pricingContext) + return report.data.isEmpty ? nil : report + } + private static func requestedWindowExpandsCache( range: CostUsageScanner.CostUsageDayRange, cache: PiSessionCostCache) -> Bool @@ -176,11 +231,11 @@ enum PiSessionCostScanner { private static func scanPiSessionFile( fileURL: URL, - range: CostUsageScanner.CostUsageDayRange, - forceRescan: Bool, - pricingContext: ModelsDevPricingContext, - cache: inout PiSessionCostCache) + cache: inout PiSessionCostCache, + context: ScanContext) + throws { + try context.checkCancellation?() let path = fileURL.path let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 @@ -192,7 +247,7 @@ enum PiSessionCostScanner { } let cached = cache.files[path] - if !forceRescan, + if !context.forceRescan, let cached, cached.mtimeUnixMs == mtimeMs, cached.size == size @@ -200,18 +255,19 @@ enum PiSessionCostScanner { return } - if !forceRescan, + if !context.forceRescan, let cached, size > cached.size, cached.parsedBytes > 0, cached.parsedBytes <= size { - let delta = self.parsePiSessionFile( + let delta = try self.parsePiSessionFile( fileURL: fileURL, - range: range, + range: context.range, startOffset: cached.parsedBytes, initialModelContext: cached.lastModelContext, - pricingContext: pricingContext) + pricingContext: context.pricingContext, + checkCancellation: context.checkCancellation) if !delta.contributions.isEmpty { self.applyContributions( daysByProvider: &cache.daysByProvider, @@ -235,10 +291,11 @@ enum PiSessionCostScanner { sign: -1) } - let parsed = self.parsePiSessionFile( + let parsed = try self.parsePiSessionFile( fileURL: fileURL, - range: range, - pricingContext: pricingContext) + range: context.range, + pricingContext: context.pricingContext, + checkCancellation: context.checkCancellation) if !parsed.contributions.isEmpty { self.applyContributions(daysByProvider: &cache.daysByProvider, contributions: parsed.contributions, sign: 1) } @@ -256,7 +313,8 @@ enum PiSessionCostScanner { range: CostUsageScanner.CostUsageDayRange, startOffset: Int64 = 0, initialModelContext: PiModelContext? = nil, - pricingContext: ModelsDevPricingContext? = nil) -> ParseResult + pricingContext: ModelsDevPricingContext? = nil, + checkCancellation: CostUsageScanner.CancellationCheck? = nil) throws -> ParseResult { var currentModelContext = initialModelContext var contributions: [String: [String: [String: PiPackedUsage]]] = [:] @@ -292,41 +350,49 @@ enum PiSessionCostScanner { } } - let parsedBytes = (try? CostUsageJsonl.scan( - fileURL: fileURL, - offset: startOffset, - maxLineBytes: Self.maxLineBytes, - prefixBytes: Self.maxLineBytes, - onLine: { line in - guard !line.bytes.isEmpty, !line.wasTruncated else { return } - autoreleasepool { - guard let object = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any] - else { return } - guard let type = object["type"] as? String else { return } - - if type == "model_change" { - currentModelContext = self.modelContext(from: object) - return + let parsedBytes: Int64 + do { + parsedBytes = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: startOffset, + maxLineBytes: Self.maxLineBytes, + prefixBytes: Self.maxLineBytes, + checkCancellation: checkCancellation, + onLine: { line in + guard !line.bytes.isEmpty, !line.wasTruncated else { return } + autoreleasepool { + guard let object = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any] + else { return } + guard let type = object["type"] as? String else { return } + + if type == "model_change" { + currentModelContext = self.modelContext(from: object) + return + } + + guard type == "message", let message = object["message"] as? [String: Any] else { return } + guard (message["role"] as? String) == "assistant" else { return } + + let identity = self.resolveAssistantIdentity( + entry: object, + message: message, + fallback: currentModelContext) + guard let identity else { return } + guard let date = self.timestampDate(entry: object, message: message) else { return } + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: date) + let usage = self.extractUsage( + provider: identity.provider, + modelName: identity.modelName, + message: message, + pricingContext: pricingContext) + add(provider: identity.provider, dayKey: dayKey, modelName: identity.modelName, usage: usage) } - - guard type == "message", let message = object["message"] as? [String: Any] else { return } - guard (message["role"] as? String) == "assistant" else { return } - - let identity = self.resolveAssistantIdentity( - entry: object, - message: message, - fallback: currentModelContext) - guard let identity else { return } - guard let date = self.timestampDate(entry: object, message: message) else { return } - let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: date) - let usage = self.extractUsage( - provider: identity.provider, - modelName: identity.modelName, - message: message, - pricingContext: pricingContext) - add(provider: identity.provider, dayKey: dayKey, modelName: identity.modelName, usage: usage) - } - })) ?? startOffset + }) + } catch is CancellationError { + throw CancellationError() + } catch { + parsedBytes = startOffset + } return ParseResult( contributions: contributions, diff --git a/Sources/CodexBarCore/ProviderHTTPClient.swift b/Sources/CodexBarCore/ProviderHTTPClient.swift index 1c8a3f85..f5ea38a4 100644 --- a/Sources/CodexBarCore/ProviderHTTPClient.swift +++ b/Sources/CodexBarCore/ProviderHTTPClient.swift @@ -35,6 +35,76 @@ public struct ProviderHTTPResponse: Sendable { } } +public struct ProviderHTTPRetryPolicy: Sendable { + public let maxRetries: Int + public let retryableStatusCodes: Set + public let retryableURLErrorCodes: Set + public let retryableMethods: Set + public let baseDelaySeconds: TimeInterval + public let maxDelaySeconds: TimeInterval + + public init( + maxRetries: Int, + retryableStatusCodes: Set = [408, 429, 500, 502, 503, 504], + retryableURLErrorCodes: Set = [ + .timedOut, + .networkConnectionLost, + .cannotConnectToHost, + .cannotFindHost, + .dnsLookupFailed, + ], + retryableMethods: Set = ["GET", "HEAD", "OPTIONS"], + baseDelaySeconds: TimeInterval = 1, + maxDelaySeconds: TimeInterval = 10) + { + self.maxRetries = max(0, maxRetries) + self.retryableStatusCodes = retryableStatusCodes + self.retryableURLErrorCodes = retryableURLErrorCodes + self.retryableMethods = retryableMethods + self.baseDelaySeconds = max(0, baseDelaySeconds) + self.maxDelaySeconds = max(0, maxDelaySeconds) + } + + public static let disabled = ProviderHTTPRetryPolicy( + maxRetries: 0, + retryableStatusCodes: [], + retryableURLErrorCodes: [], + baseDelaySeconds: 0, + maxDelaySeconds: 0) + + public static let transientIdempotent = ProviderHTTPRetryPolicy(maxRetries: 1) + + func shouldRetry(request: URLRequest, attempt: Int, statusCode: Int) -> Bool { + self.canRetry(request: request, attempt: attempt) + && self.retryableStatusCodes.contains(statusCode) + } + + func shouldRetry(request: URLRequest, attempt: Int, error: Error) -> Bool { + guard self.canRetry(request: request, attempt: attempt) else { return false } + guard let urlError = error as? URLError else { return false } + return self.retryableURLErrorCodes.contains(urlError.code) + } + + func delaySeconds(attempt: Int, response: HTTPURLResponse?) -> TimeInterval { + if let retryAfter = response?.value(forHTTPHeaderField: "Retry-After"), + let seconds = TimeInterval(retryAfter.trimmingCharacters(in: .whitespacesAndNewlines)), + seconds >= 0 + { + return min(seconds, self.maxDelaySeconds) + } + + guard self.baseDelaySeconds > 0 else { return 0 } + let multiplier = pow(2, Double(max(0, attempt))) + return min(self.baseDelaySeconds * multiplier, self.maxDelaySeconds) + } + + private func canRetry(request: URLRequest, attempt: Int) -> Bool { + guard attempt < self.maxRetries else { return false } + let method = request.httpMethod?.uppercased() ?? "GET" + return self.retryableMethods.contains(method) + } +} + public struct ProviderHTTPTransportHandler: ProviderHTTPTransport { private let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse) @@ -49,11 +119,49 @@ public struct ProviderHTTPTransportHandler: ProviderHTTPTransport { extension ProviderHTTPTransport { public func response(for request: URLRequest) async throws -> ProviderHTTPResponse { - let (data, response) = try await self.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) + try await self.response(for: request, retryPolicy: .disabled) + } + + public func response( + for request: URLRequest, + retryPolicy: ProviderHTTPRetryPolicy) async throws -> ProviderHTTPResponse + { + var attempt = 0 + + while true { + do { + let (data, response) = try await self.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + let providerResponse = ProviderHTTPResponse(data: data, response: httpResponse) + guard retryPolicy.shouldRetry( + request: request, + attempt: attempt, + statusCode: providerResponse.statusCode) + else { + return providerResponse + } + try await Self.sleepBeforeRetry(policy: retryPolicy, attempt: attempt, response: httpResponse) + attempt += 1 + } catch { + guard retryPolicy.shouldRetry(request: request, attempt: attempt, error: error) else { + throw error + } + try await Self.sleepBeforeRetry(policy: retryPolicy, attempt: attempt, response: nil) + attempt += 1 + } } - return ProviderHTTPResponse(data: data, response: httpResponse) + } + + private static func sleepBeforeRetry( + policy: ProviderHTTPRetryPolicy, + attempt: Int, + response: HTTPURLResponse?) async throws + { + let delay = policy.delaySeconds(attempt: attempt, response: response) + guard delay > 0 else { return } + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } } diff --git a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift index cf8cd64b..e59e90a8 100644 --- a/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Alibaba/AlibabaTokenPlanUsageFetcher.swift @@ -26,14 +26,14 @@ public enum AlibabaTokenPlanUsageError: LocalizedError, Sendable, Equatable { } } -// swiftlint:disable:next type_body_length public struct AlibabaTokenPlanUsageFetcher: Sendable { private static let log = CodexBarLog.logger("alibaba-token-plan") - private static let gatewayBaseURLString = "https://bailian-cs.console.aliyun.com" + private static let gatewayBaseURLString = "https://bailian.console.aliyun.com" private static let dashboardOriginURLString = "https://bailian.console.aliyun.com" private static let currentRegionID = "cn-beijing" - private static let apiName = "zeldaEasy.bailian-commerce.tokenPlan.queryTokenPlanInstanceInfo" - private static let tokenPlanCommodityCode = "sfm_tokenplanteams_dp_cn" + private static let bssServiceCode = "BssOpenAPI-V3" + private static let subscriptionSummaryAction = "GetSubscriptionSummary" + private static let tokenPlanProductCode = "sfm_tokenplanteams_dp_cn" private static let browserLikeUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" @@ -105,14 +105,12 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { apiCookieHeader: normalizedAPIHeader, environment: environment, session: dashboardSession) - let anonymousID = self.extractCookieValue(name: "cna", from: normalizedAPIHeader) Self.log.info( "Fetching Alibaba Token Plan usage", metadata: [ "apiHost": url.host ?? "unknown", "apiCookieNames": self.cookieNamesDescription(from: normalizedAPIHeader), "dashboardCookieNames": self.cookieNamesDescription(from: normalizedDashboardHeader), - "hasAnonymousID": anonymousID == nil ? "0" : "1", "hasCSRF": self.hasCSRF(in: normalizedAPIHeader) ? "1" : "0", "secTokenSource": secToken == nil ? "missing" : "resolved", ]) @@ -120,7 +118,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 20 - request.httpBody = self.queryTokenPlanRequestBody(secToken: secToken, anonymousID: anonymousID) + request.httpBody = self.subscriptionSummaryRequestBody(secToken: secToken) request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") request.setValue("*/*", forHTTPHeaderField: "Accept") request.setValue(normalizedAPIHeader, forHTTPHeaderField: "Cookie") @@ -202,10 +200,9 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { var components = URLComponents(string: Self.gatewayBaseURLString)! components.path = "/data/api.json" components.queryItems = [ - URLQueryItem(name: "action", value: "BroadScopeAspnGateway"), - URLQueryItem(name: "product", value: "sfm_bailian"), - URLQueryItem(name: "api", value: Self.apiName), - URLQueryItem(name: "_v", value: "undefined"), + URLQueryItem(name: "action", value: Self.subscriptionSummaryAction), + URLQueryItem(name: "product", value: Self.bssServiceCode), + URLQueryItem(name: "_tag", value: ""), ] return components.url! } @@ -231,15 +228,16 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { try self.throwIfErrorPayload(dictionary) - let instance = self.findTokenPlanInstance(in: dictionary) - let planName = self.findPlanName(in: instance ?? [:]) ?? self.findPlanName(in: dictionary) - let quotaSource = self.findQuotaInfo(in: instance ?? [:]) ?? self.findQuotaInfo(in: dictionary) - let used = quotaSource.flatMap { self.anyDouble(for: Self.usedQuotaKeys, in: $0) } - let total = quotaSource.flatMap { self.anyDouble(for: Self.totalQuotaKeys, in: $0) } - let remaining = quotaSource.flatMap { self.anyDouble(for: Self.remainingQuotaKeys, in: $0) } - let resetsAt = self.findResetDate(in: instance ?? [:]) ?? self.findResetDate(in: dictionary) + let summary = self.findSubscriptionSummary(in: dictionary) ?? dictionary + let total = self.anyDouble(for: Self.totalQuotaKeys, in: summary) + let remaining = self.anyDouble(for: Self.remainingQuotaKeys, in: summary) + let used = self.anyDouble(for: Self.usedQuotaKeys, in: summary) ?? + total.flatMap { total in remaining.map { max(0, total - $0) } } + let resetsAt = self.findResetDate(in: summary) ?? self.findResetDate(in: dictionary) + let totalCount = self.anyDouble(for: Self.subscriptionCountKeys, in: summary) + let planName = self.findPlanName(in: summary) ?? ((totalCount ?? 0) > 0 || total != nil ? "TOKEN PLAN" : nil) - if planName == nil, total == nil, used == nil, remaining == nil { + if planName == nil, total == nil, used == nil, remaining == nil, totalCount == nil { let diagnostics = self.payloadDiagnostics(payload: dictionary) Self.log.error("Alibaba Token Plan payload missing expected fields: \(diagnostics)") throw AlibabaTokenPlanUsageError.parseFailed("Missing token plan data (\(diagnostics))") @@ -254,36 +252,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { updatedAt: now) } - private static func queryTokenPlanRequestBody(secToken: String?, anonymousID: String?) -> Data { - let traceID = UUID().uuidString.lowercased() - var cornerstoneParam: [String: Any] = [ - "feTraceId": traceID, - "feURL": Self.dashboardURL.absoluteString, - "protocol": "V2", - "console": "ONE_CONSOLE", - "productCode": "p_efm", - "domain": "bailian.console.aliyun.com", - "consoleSite": "BAILIAN_ALIYUN", - "userNickName": "", - "userPrincipalName": "", - "xsp_lang": "zh-CN", - ] - if let anonymousID, !anonymousID.isEmpty { - cornerstoneParam["X-Anonymous-Id"] = anonymousID - } - - let paramsObject: [String: Any] = [ - "Api": Self.apiName, - "V": "1.0", - "Data": [ - "queryTokenPlanInstanceInfoRequest": [ - "commodityCode": Self.tokenPlanCommodityCode, - "onlyLatestOne": true, - ], - "cornerstoneParam": cornerstoneParam, - ], - ] - + private static func subscriptionSummaryRequestBody(secToken: String?) -> Data { + let paramsObject = ["ProductCode": Self.tokenPlanProductCode] guard let paramsData = try? JSONSerialization.data(withJSONObject: paramsObject, options: []), let paramsString = String(data: paramsData, encoding: .utf8) else { @@ -292,6 +262,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { var components = URLComponents() var queryItems = [ + URLQueryItem(name: "product", value: Self.bssServiceCode), + URLQueryItem(name: "action", value: Self.subscriptionSummaryAction), URLQueryItem(name: "params", value: paramsString), URLQueryItem(name: "region", value: Self.currentRegionID), ] @@ -436,6 +408,16 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } private static func throwIfErrorPayload(_ dictionary: [String: Any]) throws { + if self.findBoolValues(forKeys: ["Success", "success"], in: dictionary).contains(false) { + let message = self.findFirstString(forKeys: ["Message", "message", "msg", "Code", "code"], in: dictionary) + ?? "request was not successful" + let lowered = message.lowercased() + if lowered.contains("needlogin") || lowered.contains("login") { + throw AlibabaTokenPlanUsageError.loginRequired + } + throw AlibabaTokenPlanUsageError.apiError(message) + } + if let statusCode = self.findFirstInt(forKeys: ["statusCode", "status_code", "code"], in: dictionary), statusCode != 0, statusCode != 200 @@ -473,6 +455,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "instance_name", "displayName", "display_name", + "ProductName", + "productName", "name", "title", "planType", @@ -488,6 +472,10 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "used", "usedAmount", "consumeAmount", + "usedValue", + "UsedValue", + "consumedValue", + "ConsumedValue", ] private static let totalQuotaKeys = [ "totalQuota", @@ -499,6 +487,8 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "creditsTotal", "monthlyTotalQuota", "amount", + "totalValue", + "TotalValue", ] private static let remainingQuotaKeys = [ "remainingQuota", @@ -510,6 +500,16 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "remaining", "availableAmount", "remainAmount", + "totalSurplusValue", + "TotalSurplusValue", + "surplusValue", + "SurplusValue", + ] + private static let subscriptionCountKeys = [ + "totalCount", + "TotalCount", + "subscriptionTotalNumber", + "SubscriptionTotalNumber", ] private static let resetDateKeys = [ "nextRefreshTime", @@ -522,24 +522,27 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { "endTime", "validEndTime", "instanceEndTime", + "nearestExpireDate", + "NearestExpireDate", ] - private static func findTokenPlanInstance(in payload: [String: Any]) -> [String: Any]? { - if let direct = self.findFirstDictionary( - forKeys: ["tokenPlanInstanceInfo", "token_plan_instance_info", "instanceInfo", "instance_info"], - in: payload) + private static func findSubscriptionSummary(in payload: [String: Any]) -> [String: Any]? { + if let data = self.findFirstDictionary( + forKeys: ["Data", "data", "successResponse", "success_response"], + in: payload), + self.containsSubscriptionSummaryFields(data) { - return direct + return data } - if let infos = self.findFirstArray( - forKeys: ["tokenPlanInstanceInfos", "token_plan_instance_infos", "instanceInfos", "instances"], + return self.findFirstDictionary( + matchingAnyKey: Self.usedQuotaKeys + Self.totalQuotaKeys + Self.remainingQuotaKeys + + Self.subscriptionCountKeys, in: payload) - { - return infos.compactMap { $0 as? [String: Any] }.max { - self.activeSignalScore(in: $0) < self.activeSignalScore(in: $1) - } - } - return nil + } + + private static func containsSubscriptionSummaryFields(_ payload: [String: Any]) -> Bool { + let keys = self.usedQuotaKeys + self.totalQuotaKeys + self.remainingQuotaKeys + self.subscriptionCountKeys + return keys.contains { payload[$0] != nil } } private static func findPlanName(in payload: [String: Any]) -> String? { @@ -547,18 +550,6 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { self.findFirstString(forKeys: self.planNameKeys, in: payload) } - private static func findQuotaInfo(in payload: [String: Any]) -> [String: Any]? { - if let direct = self.findFirstDictionary( - forKeys: ["quotaInfo", "quota_info", "tokenPlanQuotaInfo", "token_plan_quota_info"], - in: payload) - { - return direct - } - return self.findFirstDictionary( - matchingAnyKey: Self.usedQuotaKeys + Self.totalQuotaKeys + Self.remainingQuotaKeys, - in: payload) - } - private static func findResetDate(in payload: [String: Any]) -> Date? { self.anyDate(for: self.resetDateKeys, in: payload) ?? self.findFirstDate(forKeys: self.resetDateKeys, in: payload) @@ -566,12 +557,11 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { private static func payloadDiagnostics(payload: [String: Any]) -> String { let topKeys = payload.keys.sorted() - let dataDict = self.findFirstDictionary(forKeys: ["data", "successResponse", "success_response"], in: payload) + let dataDict = self.findFirstDictionary( + forKeys: ["Data", "data", "successResponse", "success_response"], + in: payload) let dataKeys = dataDict?.keys.sorted() ?? [] - let instance = self.findTokenPlanInstance(in: payload) - let instanceKeys = instance?.keys.sorted() ?? [] - return "topKeys=\(topKeys.joined(separator: ",")) dataKeys=\(dataKeys.joined(separator: ",")) " + - "instanceKeys=\(instanceKeys.joined(separator: ","))" + return "topKeys=\(topKeys.joined(separator: ",")) dataKeys=\(dataKeys.joined(separator: ","))" } private static func isLikelyLoginHTML(_ data: Data) -> Bool { @@ -580,21 +570,6 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { (text.contains("login") || text.contains("sign in") || text.contains("signin")) } - private static func activeSignalScore(in source: [String: Any]) -> Int { - if let status = self.anyString(for: ["status", "instanceStatus", "state"], in: source)?.uppercased() { - if ["VALID", "ACTIVE", "NORMAL"].contains(status) { - return 3 - } - if ["EXPIRED", "INVALID", "INACTIVE", "DISABLED", "TERMINATED", "STOPPED"].contains(status) { - return -1 - } - } - if let isActive = self.anyBool(for: ["isActive", "active"], in: source) { - return isActive ? 3 : -1 - } - return 0 - } - private static func findFirstDictionary(forKeys keys: [String], in value: Any) -> [String: Any]? { if let dict = value as? [String: Any] { for key in keys { @@ -641,30 +616,6 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return nil } - private static func findFirstArray(forKeys keys: [String], in value: Any) -> [Any]? { - if let dict = value as? [String: Any] { - for key in keys { - if let array = dict[key] as? [Any] { - return array - } - } - for nestedValue in dict.values { - if let found = self.findFirstArray(forKeys: keys, in: nestedValue) { - return found - } - } - return nil - } - if let array = value as? [Any] { - for item in array { - if let found = self.findFirstArray(forKeys: keys, in: item) { - return found - } - } - } - return nil - } - private static func findFirstString(forKeys keys: [String], in value: Any) -> String? { if let dict = value as? [String: Any] { for key in keys { @@ -689,6 +640,18 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { return nil } + private static func findBoolValues(forKeys keys: [String], in value: Any) -> [Bool] { + if let dict = value as? [String: Any] { + let directValues = keys.compactMap { self.parseBool(dict[$0]) } + let nestedValues = dict.values.flatMap { self.findBoolValues(forKeys: keys, in: $0) } + return directValues + nestedValues + } + if let array = value as? [Any] { + return array.flatMap { self.findBoolValues(forKeys: keys, in: $0) } + } + return [] + } + private static func findFirstInt(forKeys keys: [String], in value: Any) -> Int? { if let dict = value as? [String: Any] { for key in keys { @@ -838,7 +801,7 @@ public struct AlibabaTokenPlanUsageFetcher: Sendable { } let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") - for format in ["yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss"] { + for format in ["yyyy-MM-dd", "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss"] { dateFormatter.dateFormat = format if let date = dateFormatter.date(from: string) { return date diff --git a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift index dbe39d4c..e80e48cf 100644 --- a/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Amp/AmpUsageFetcher.swift @@ -371,13 +371,18 @@ public struct AmpUsageFetcher: Sendable { } static func shouldAttachCookie(to url: URL?) -> Bool { + guard url?.scheme?.lowercased() == "https" else { return false } + return self.isAmpHost(url) + } + + private static func isAmpHost(_ url: URL?) -> Bool { guard let host = url?.host?.lowercased() else { return false } if host == "ampcode.com" || host == "www.ampcode.com" { return true } return host.hasSuffix(".ampcode.com") } static func isLoginRedirect(_ url: URL) -> Bool { - guard self.shouldAttachCookie(to: url) else { return false } + guard self.isAmpHost(url) else { return false } let path = url.path.lowercased() let components = path.split(separator: "/").map(String.init) diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift index cb1dadad..84524b18 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityRemoteUsageFetcher.swift @@ -146,7 +146,8 @@ public struct AntigravityRemoteUsageFetcher: Sendable { return AntigravityStatusSnapshot( modelQuotas: models, accountEmail: claims.email, - accountPlan: Self.resolvePlan(response: codeAssist, claims: claims)) + accountPlan: Self.resolvePlan(response: codeAssist, claims: claims), + source: .remote) } private static func shouldRefresh(expiryDate: Date?, now: Date) -> Bool { diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 1d141adf..e1d3bbb7 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -37,25 +37,48 @@ private enum AntigravityModelFamily { case unknown } +private struct AntigravityModelVersion: Comparable { + let major: Int + let minor: Int + + static func < (lhs: AntigravityModelVersion, rhs: AntigravityModelVersion) -> Bool { + if lhs.major != rhs.major { return lhs.major < rhs.major } + return lhs.minor < rhs.minor + } +} + private struct AntigravityNormalizedModel { let quota: AntigravityModelQuota let family: AntigravityModelFamily let selectionPriority: Int? + let isImage: Bool + let isLite: Bool + let isAutocomplete: Bool + let version: AntigravityModelVersion? + let tier: Int +} + +public enum AntigravityModelQuotaSource: Sendable { + case local + case remote } public struct AntigravityStatusSnapshot: Sendable { public let modelQuotas: [AntigravityModelQuota] public let accountEmail: String? public let accountPlan: String? + public let source: AntigravityModelQuotaSource public init( modelQuotas: [AntigravityModelQuota], accountEmail: String?, - accountPlan: String?) + accountPlan: String?, + source: AntigravityModelQuotaSource = .remote) { self.modelQuotas = modelQuotas self.accountEmail = accountEmail self.accountPlan = accountPlan + self.source = source } public func toUsageSnapshot() throws -> UsageSnapshot { @@ -64,13 +87,19 @@ public struct AntigravityStatusSnapshot: Sendable { } let normalized = Self.normalizedModels(self.modelQuotas) - let primaryQuota = Self.representative(for: .claude, in: normalized) - let secondaryQuota = Self.representative(for: .geminiPro, in: normalized) - let tertiaryQuota = Self.representative(for: .geminiFlash, in: normalized) + let summaryModels: [AntigravityNormalizedModel] = switch self.source { + case .local: + normalized + case .remote: + normalized.filter(Self.isRemoteSummaryCandidate) + } + let primaryQuota = Self.representative(for: .claude, in: summaryModels) + let secondaryQuota = Self.representative(for: .geminiPro, in: summaryModels) + let tertiaryQuota = Self.representative(for: .geminiFlash, in: summaryModels) let fallbackQuota: AntigravityModelQuota? = if primaryQuota == nil, secondaryQuota == nil, tertiaryQuota == nil { - Self.fallbackRepresentative(in: normalized) + Self.fallbackRepresentative(in: summaryModels) } else { nil } @@ -79,6 +108,24 @@ public struct AntigravityStatusSnapshot: Sendable { let secondary = secondaryQuota.map(Self.rateWindow(for:)) let tertiary = tertiaryQuota.map(Self.rateWindow(for:)) + // primary/secondary/tertiary keep the 3-family summary for back-compat. + // extraRateWindows carries a source-aware set: the full curated list for + // .local (verified junk-free), and a filtered list for .remote (catalog noise + // hidden, consumed quota always kept). Sorted by family→version→tier. + let shownModels: [AntigravityNormalizedModel] = switch self.source { + case .local: + normalized + case .remote: + normalized.filter { m in + Self.isRemoteSummaryCandidate(m) || (m.quota.remainingFraction ?? 1.0) < 0.999 + } + } + let extraWindows = shownModels + .sorted(by: Self.modelOrderPrecedes) + .map { m in + NamedRateWindow(id: m.quota.modelId, title: m.quota.label, window: Self.rateWindow(for: m.quota)) + } + let identity = ProviderIdentitySnapshot( providerID: .antigravity, accountEmail: self.accountEmail, @@ -88,6 +135,7 @@ public struct AntigravityStatusSnapshot: Sendable { primary: primary, secondary: secondary, tertiary: tertiary, + extraRateWindows: extraWindows.isEmpty ? nil : extraWindows, updatedAt: Date(), identity: identity) } @@ -100,6 +148,53 @@ public struct AntigravityStatusSnapshot: Sendable { resetDescription: quota.resetDescription) } + private static func modelOrderPrecedes( + _ lhs: AntigravityNormalizedModel, + _ rhs: AntigravityNormalizedModel) -> Bool + { + // 1. Family rank: claude=0, geminiPro=1, geminiFlash=2, unknown=3 + let lhsFamilyRank = Self.familyRank(lhs.family) + let rhsFamilyRank = Self.familyRank(rhs.family) + if lhsFamilyRank != rhsFamilyRank { + return lhsFamilyRank < rhsFamilyRank + } + + // 2. Version descending (newer first); nil version sorts after non-nil + switch (lhs.version, rhs.version) { + case let (.some(lv), .some(rv)): + if lv != rv { + return lv > rv + } + case (.some, .none): + return true + case (.none, .some): + return false + case (.none, .none): + break + } + + // 3. Tier ascending: High(0) < Medium(1) < Low(2) + if lhs.tier != rhs.tier { + return lhs.tier < rhs.tier + } + + // 4. Label tiebreaker + return lhs.quota.label.localizedCaseInsensitiveCompare(rhs.quota.label) == .orderedAscending + } + + private static func familyRank(_ family: AntigravityModelFamily) -> Int { + switch family { + case .claude: 0 + case .geminiPro: 1 + case .geminiFlash: 2 + case .unknown: 3 + } + } + + private static func isRemoteSummaryCandidate(_ model: AntigravityNormalizedModel) -> Bool { + model.family != .unknown && !model.isLite && !model.isAutocomplete && !model.isImage + } + private static func normalizedModels(_ models: [AntigravityModelQuota]) -> [AntigravityNormalizedModel] { models.map { self.normalizeModel($0) } } @@ -112,6 +207,8 @@ public struct AntigravityStatusSnapshot: Sendable { let isLite = modelId.contains("lite") || label.contains("lite") let isAutocomplete = modelId.contains("autocomplete") || label.contains("autocomplete") || modelId .hasPrefix("tab_") + let isImage = modelId.contains("image") || label.contains("image") + let isSelectableTextModel = !isLite && !isAutocomplete && !isImage let isLowPriorityGeminiPro = modelId.contains("pro-low") || (label.contains("pro") && label.contains("low")) @@ -119,23 +216,59 @@ public struct AntigravityStatusSnapshot: Sendable { case .claude: 0 case .geminiPro: - if isLowPriorityGeminiPro { + if isLowPriorityGeminiPro, isSelectableTextModel { 0 - } else if !isLite, !isAutocomplete { + } else if isSelectableTextModel { 1 } else { nil } case .geminiFlash: - (!isLite && !isAutocomplete) ? 0 : nil + isSelectableTextModel ? 0 : nil case .unknown: nil } + let version = Self.parseVersion(from: label) + let tier = Self.parseTier(from: label, modelId: modelId) + return AntigravityNormalizedModel( quota: quota, family: family, - selectionPriority: selectionPriority) + selectionPriority: selectionPriority, + isImage: isImage, + isLite: isLite, + isAutocomplete: isAutocomplete, + version: version, + tier: tier) + } + + private static func parseVersion(from label: String) -> AntigravityModelVersion? { + // Accept either "." or "-" between major and minor so a raw model id used as the + // label when displayName is missing (e.g. "gemini-3-1-pro-low") still parses 3.1. + guard let regex = try? NSRegularExpression(pattern: #"(\d+)(?:[.\-](\d+))?"#) else { return nil } + let nsLabel = label as NSString + let range = NSRange(location: 0, length: nsLabel.length) + guard let match = regex.firstMatch(in: label, options: [], range: range) else { return nil } + let majorRange = Range(match.range(at: 1), in: label) + guard let majorRange, let major = Int(label[majorRange]) else { return nil } + let minor: Int = if match.range(at: 2).location != NSNotFound, + let minorRange = Range(match.range(at: 2), in: label), + let parsed = Int(label[minorRange]) + { + parsed + } else { + 0 + } + return AntigravityModelVersion(major: major, minor: minor) + } + + private static func parseTier(from label: String, modelId: String) -> Int { + let combined = label + " " + modelId + if combined.contains("high") { return 0 } + if combined.contains("medium") { return 1 } + if combined.contains("low") { return 2 } + return 1 } private static func representative( @@ -364,7 +497,8 @@ public struct AntigravityStatusProbe: Sendable { return AntigravityStatusSnapshot( modelQuotas: models, accountEmail: email, - accountPlan: planName) + accountPlan: planName, + source: .local) } static func parsePlanInfoSummary(_ data: Data) throws -> AntigravityPlanInfoSummary? { @@ -393,7 +527,7 @@ public struct AntigravityStatusProbe: Sendable { } let modelConfigs = response.clientModelConfigs ?? [] let models = modelConfigs.compactMap(Self.quotaFromConfig(_:)) - return AntigravityStatusSnapshot(modelQuotas: models, accountEmail: nil, accountPlan: nil) + return AntigravityStatusSnapshot(modelQuotas: models, accountEmail: nil, accountPlan: nil, source: .local) } private static func quotaFromConfig(_ config: ModelConfig) -> AntigravityModelQuota? { diff --git a/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift b/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift index 19eb38e9..ddbb6de2 100644 --- a/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift +++ b/Sources/CodexBarCore/Providers/Augment/AuggieCLIProbe.swift @@ -52,11 +52,16 @@ public struct AuggieCLIProbe: Sendable { return output } - private func parse(_ output: String) throws -> AugmentStatusSnapshot { - // Parse output like: + func parse(_ output: String) throws -> AugmentStatusSnapshot { + // Legacy output: // Max Plan 450,000 credits / month // 11,657 remaining · 953,170 / 964,827 credits used // 2 days remaining in this billing cycle (ends 1/8/2026) + // + // Current output (2026+): + // 319,054 credits remaining Max Plan + // 450,000 credits / month + // 9 days remaining in this billing cycle (ends 6/9/2026) var maxCredits: Int? var remaining: Int? @@ -67,8 +72,15 @@ public struct AuggieCLIProbe: Sendable { for line in output.split(separator: "\n") { let trimmed = line.trimmingCharacters(in: .whitespaces) - // Parse "Max Plan 450,000 credits / month" - if trimmed.contains("Max Plan"), trimmed.contains("credits") { + if trimmed.contains("credits / month") { + if let match = trimmed.range(of: #"([\d,]+)\s+credits\s*/\s*month"#, options: .regularExpression) { + let numberStr = String(trimmed[match]).replacingOccurrences(of: ",", with: "") + .replacingOccurrences(of: " credits", with: "") + .replacingOccurrences(of: " / month", with: "") + maxCredits = Int(numberStr) + total = total ?? Int(numberStr) + } + } else if trimmed.contains("Max Plan"), trimmed.contains("credits"), !trimmed.contains("remaining") { if let match = trimmed.range(of: #"([\d,]+)\s+credits"#, options: .regularExpression) { let numberStr = String(trimmed[match]).replacingOccurrences(of: ",", with: "") .replacingOccurrences(of: " credits", with: "") @@ -76,9 +88,17 @@ public struct AuggieCLIProbe: Sendable { } } + if trimmed.contains("credits remaining"), !trimmed.contains("billing cycle") { + if let match = trimmed.range(of: #"([\d,]+)\s+credits\s+remaining"#, options: .regularExpression) { + let numberStr = String(trimmed[match]).replacingOccurrences(of: ",", with: "") + .replacingOccurrences(of: " credits", with: "") + .replacingOccurrences(of: " remaining", with: "") + remaining = Int(numberStr) + } + } + // Parse "11,657 remaining · 953,170 / 964,827 credits used" if trimmed.contains("remaining"), trimmed.contains("credits used") { - // Extract remaining if let remMatch = trimmed.range(of: #"([\d,]+)\s+remaining"#, options: .regularExpression) { let numStr = String(trimmed[remMatch]) .replacingOccurrences(of: ",", with: "") @@ -86,7 +106,6 @@ public struct AuggieCLIProbe: Sendable { remaining = Int(numStr) } - // Extract used / total if let usedMatch = trimmed.range( of: #"([\d,]+)\s*/\s*([\d,]+)\s+credits used"#, options: .regularExpression) @@ -103,15 +122,12 @@ public struct AuggieCLIProbe: Sendable { } } - // Parse "2 days remaining in this billing cycle (ends 1/8/2026)" if trimmed.contains("billing cycle"), trimmed.contains("ends") { - // Extract date from "(ends 1/8/2026)" if let dateMatch = trimmed.range(of: #"ends\s+([\d/]+)"#, options: .regularExpression) { let dateStr = String(trimmed[dateMatch]) .replacingOccurrences(of: "ends", with: "") .trimmingCharacters(in: .whitespaces) - // Parse date like "1/8/2026" let formatter = DateFormatter() formatter.dateFormat = "M/d/yyyy" formatter.locale = Locale(identifier: "en_US_POSIX") @@ -121,11 +137,19 @@ public struct AuggieCLIProbe: Sendable { } } - guard let finalRemaining = remaining, let finalUsed = used, let finalTotal = total else { + guard let finalRemaining = remaining else { Self.log.error("Failed to parse auggie output: \(output)") throw AuggieCLIError.parseError("Could not extract credits from output") } + let finalTotal = total ?? maxCredits + guard let finalTotal else { + Self.log.error("Failed to parse auggie output: \(output)") + throw AuggieCLIError.parseError("Could not extract credits from output") + } + + let finalUsed = used ?? max(0, finalTotal - finalRemaining) + return AugmentStatusSnapshot( creditsRemaining: Double(finalRemaining), creditsUsed: Double(finalUsed), diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift index 4ccb0987..7c259d4d 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentProviderDescriptor.swift @@ -95,10 +95,8 @@ struct AugmentCLIFetchStrategy: ProviderFetchStrategy { // Fallback to web if CLI fails (not authenticated, etc.) if let cliError = error as? AuggieCLIError { switch cliError { - case .notAuthenticated, .noOutput: + case .notAuthenticated, .noOutput, .parseError: return true - case .parseError: - return false // Don't fallback on parse errors - something is wrong } } return true diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift b/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift index 40e1974a..7ffa2aea 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentSessionKeepalive.swift @@ -212,6 +212,12 @@ public final class AugmentSessionKeepalive { try await Task.sleep(for: .seconds(1)) // Brief delay for browser to update cookies let newSession = try AugmentCookieImporter.importSession(logger: self.logger) + await AugmentSessionStore.shared.setCookies(newSession.cookies) + CookieHeaderCache.store( + provider: .augment, + cookieHeader: newSession.cookieHeader, + sourceLabel: newSession.sourceLabel) + self.log( "✅ Session refresh successful - imported \(newSession.cookies.count) cookies " + "from \(newSession.sourceLabel)") @@ -220,6 +226,11 @@ public final class AugmentSessionKeepalive { // Reset failure tracking on success self.consecutiveFailures = 0 self.hasGivenUp = false + + if let callback = self.onSessionRecovered { + self.log("🔄 Triggering usage refresh after session refresh") + await callback() + } } else { self.log("⚠️ Session refresh returned no new cookies") self.consecutiveFailures += 1 diff --git a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift index cff8957a..52ae33ae 100644 --- a/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Augment/AugmentStatusProbe.swift @@ -15,12 +15,15 @@ public enum AugmentCookieImporter { /// NOTE: This list may not be exhaustive. If authentication fails with cookies present, /// check debug logs for cookie names and report them. private static let sessionCookieNames: Set = [ - "_session", // Legacy session cookie + "session", // Augment auth session (auth.augmentcode.com) + "_session", // Legacy session cookie (app.augmentcode.com) + "web_rpc_proxy_session", // Augment RPC proxy session "auth0", // Auth0 session "auth0.is.authenticated", // Auth0 authentication flag "a0.spajs.txs", // Auth0 SPA transaction state "__Secure-next-auth.session-token", // NextAuth secure session "next-auth.session-token", // NextAuth session + "__Secure-authjs.session-token", // AuthJS secure session "__Host-authjs.csrf-token", // AuthJS CSRF token "authjs.session-token", // AuthJS session ] diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockCredentialResolver.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockCredentialResolver.swift new file mode 100644 index 00000000..c5f3cac1 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockCredentialResolver.swift @@ -0,0 +1,78 @@ +import Foundation + +/// Resolves Bedrock signing credentials + region for the configured auth mode. +/// +/// Shared by the main usage fetch (`BedrockAPIFetchStrategy`) and the daily +/// cost-history refresh (`CostUsageFetcher`) so both honor AWS-profile auth +/// identically instead of the history path silently requiring static keys. +enum BedrockCredentialResolver { + struct Resolved { + let credentials: BedrockAWSSigner.Credentials + let region: String + } + + static func resolve( + environment: [String: String], + resolveAWSBinary: ([String: String]) -> String? = { BinaryLocator.resolveAWSBinary(env: $0) }, + makeProvider: (String) -> BedrockProfileCredentialProvider = { + BedrockProfileCredentialProvider.live(awsBinaryPath: $0) + }) async throws -> Resolved + { + switch BedrockSettingsReader.authMode(environment: environment) { + case .keys: + guard let accessKeyID = BedrockSettingsReader.accessKeyID(environment: environment), + let secretAccessKey = BedrockSettingsReader.secretAccessKey(environment: environment) + else { + throw BedrockUsageError.missingCredentials + } + let credentials = BedrockAWSSigner.Credentials( + accessKeyID: accessKeyID, + secretAccessKey: secretAccessKey, + sessionToken: BedrockSettingsReader.sessionToken(environment: environment)) + return Resolved( + credentials: credentials, + region: BedrockSettingsReader.region(environment: environment)) + + case .profile: + guard let profile = BedrockSettingsReader.profile(environment: environment) else { + throw BedrockUsageError.missingCredentials + } + guard let awsBinary = resolveAWSBinary(environment) else { + throw BedrockUsageError.awsCLINotFound + } + let cliEnvironment = Self.profileCLIEnvironment(environment) + let provider = makeProvider(awsBinary) + let credentials = try await provider.exportCredentials(profile: profile, environment: cliEnvironment) + let region = try await Self.resolveRegion( + provider: provider, + profile: profile, + environment: cliEnvironment) + return Resolved(credentials: credentials, region: region) + } + } + + private static func profileCLIEnvironment(_ environment: [String: String]) -> [String: String] { + var cliEnvironment = environment + // `--profile` selects the requested profile. Leaving AWS_PROFILE set can + // break assume-role profiles that use `credential_source = Environment`; + // keep the source credential variables themselves available. + cliEnvironment[BedrockSettingsReader.profileKey] = nil + return cliEnvironment + } + + private static func resolveRegion( + provider: BedrockProfileCredentialProvider, + profile: String, + environment: [String: String]) async throws -> String + { + if let explicit = BedrockSettingsReader.cleaned(environment[BedrockSettingsReader.regionKeys[0]]) + ?? BedrockSettingsReader.cleaned(environment[BedrockSettingsReader.regionKeys[1]]) + { + return explicit + } + if let derived = try await provider.resolveRegion(profile: profile, environment: environment) { + return derived + } + return BedrockSettingsReader.defaultRegion + } +} diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockProfileCredentialProvider.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockProfileCredentialProvider.swift new file mode 100644 index 00000000..1f692f9a --- /dev/null +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockProfileCredentialProvider.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Resolves AWS credentials for a named profile by shelling out to the AWS CLI. +/// +/// Uses `aws configure export-credentials`, which transparently resolves static +/// credentials, SSO sessions, assume-role chains, and `credential_process` profiles. +/// The runner is injected so tests never invoke a real `aws` binary. +struct BedrockProfileCredentialProvider { + typealias Runner = @Sendable ( + _ arguments: [String], + _ environment: [String: String]) async throws -> SubprocessResult + + let awsBinaryPath: String + let run: Runner + + /// Production provider that drives the real `aws` binary via `SubprocessRunner`. + static func live(awsBinaryPath: String) -> BedrockProfileCredentialProvider { + BedrockProfileCredentialProvider(awsBinaryPath: awsBinaryPath) { arguments, environment in + try await SubprocessRunner.run( + binary: awsBinaryPath, + arguments: arguments, + environment: environment, + timeout: 20, + label: "aws-bedrock-credentials") + } + } + + func exportCredentials( + profile: String, + environment: [String: String] = [:]) async throws -> BedrockAWSSigner.Credentials + { + let result: SubprocessResult + do { + result = try await self.run( + ["configure", "export-credentials", "--profile", profile, "--format", "process"], + environment) + } catch let SubprocessRunnerError.nonZeroExit(_, stderr) { + throw Self.mapExportError(stderr: stderr, profile: profile) + } + + guard let data = result.stdout.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let accessKeyID = Self.nonEmpty(json["AccessKeyId"] as? String), + let secretAccessKey = Self.nonEmpty(json["SecretAccessKey"] as? String) + else { + throw BedrockUsageError.parseFailed("Could not parse AWS CLI export-credentials output") + } + + return BedrockAWSSigner.Credentials( + accessKeyID: accessKeyID, + secretAccessKey: secretAccessKey, + sessionToken: Self.nonEmpty(json["SessionToken"] as? String)) + } + + /// Returns the profile's configured region, or `nil` when unset. + /// `aws configure get region` exits non-zero when the value is not configured, + /// which is a normal case rather than an error. + func resolveRegion( + profile: String, + environment: [String: String] = [:]) async throws -> String? + { + do { + let result = try await self.run( + ["configure", "get", "region", "--profile", profile], + environment) + return Self.nonEmpty(result.stdout) + } catch SubprocessRunnerError.nonZeroExit { + return nil + } + } + + static func mapExportError(stderr: String, profile: String) -> BedrockUsageError { + let lower = stderr.lowercased() + if lower.contains("sso login") || lower.contains("expired") || lower.contains("token has expired") { + return .profileSessionExpired(profile) + } + let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines) + return .apiError(trimmed.isEmpty ? "AWS CLI failed to export credentials" : trimmed) + } + + private static func nonEmpty(_ raw: String?) -> String? { + guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } +} diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift index ed65f00c..deaf6008 100644 --- a/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift @@ -30,7 +30,9 @@ public enum BedrockProviderDescriptor { color: ProviderColor(red: 1, green: 0.6, blue: 0)), tokenCost: ProviderTokenCostConfig( supportsTokenCost: true, - noDataMessage: { "No AWS Bedrock cost data available. Check your AWS credentials." }), + noDataMessage: { "No AWS Bedrock cost data available. Check your AWS access keys " + + "or profile, and that the AWS CLI is installed for profile auth." + }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [BedrockAPIFetchStrategy()] })), @@ -46,26 +48,20 @@ struct BedrockAPIFetchStrategy: ProviderFetchStrategy { let kind: ProviderFetchKind = .apiToken func isAvailable(_ context: ProviderFetchContext) async -> Bool { - BedrockSettingsReader.hasCredentials(environment: context.env) + switch BedrockSettingsReader.authMode(environment: context.env) { + case .keys: + BedrockSettingsReader.hasCredentials(environment: context.env) + case .profile: + BedrockSettingsReader.profile(environment: context.env) != nil + } } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let accessKeyID = BedrockSettingsReader.accessKeyID(environment: context.env), - let secretAccessKey = BedrockSettingsReader.secretAccessKey(environment: context.env) - else { - throw BedrockUsageError.missingCredentials - } - - let credentials = BedrockAWSSigner.Credentials( - accessKeyID: accessKeyID, - secretAccessKey: secretAccessKey, - sessionToken: BedrockSettingsReader.sessionToken(environment: context.env)) - let region = BedrockSettingsReader.region(environment: context.env) + let resolved = try await BedrockCredentialResolver.resolve(environment: context.env) let budget = BedrockSettingsReader.budget(environment: context.env) - let usage = try await BedrockUsageFetcher.fetchUsage( - credentials: credentials, - region: region, + credentials: resolved.credentials, + region: resolved.region, budget: budget, environment: context.env) return self.makeResult( diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift index 4959d8b1..aa1a6447 100644 --- a/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift @@ -1,5 +1,10 @@ import Foundation +public enum BedrockAuthMode: String, Codable, Sendable, CaseIterable { + case keys + case profile +} + public enum BedrockSettingsReader { public static let accessKeyIDKey = "AWS_ACCESS_KEY_ID" public static let secretAccessKeyKey = "AWS_SECRET_ACCESS_KEY" @@ -7,6 +12,8 @@ public enum BedrockSettingsReader { public static let regionKeys = ["AWS_REGION", "AWS_DEFAULT_REGION"] public static let budgetKey = "CODEXBAR_BEDROCK_BUDGET" public static let apiURLKey = "CODEXBAR_BEDROCK_API_URL" + public static let profileKey = "AWS_PROFILE" + public static let authModeKey = "CODEXBAR_BEDROCK_AUTH_MODE" public static let defaultRegion = "us-east-1" public static func accessKeyID(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { @@ -44,13 +51,44 @@ public enum BedrockSettingsReader { return value } - public static func hasCredentials( - environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + public static func profile( + environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.profileKey]) + } + + public static func authMode( + environment: [String: String] = ProcessInfo.processInfo.environment) -> BedrockAuthMode + { + if let raw = self.cleaned(environment[self.authModeKey])?.lowercased(), + let mode = BedrockAuthMode(rawValue: raw) + { + return mode + } + if self.profile(environment: environment) != nil, + !self.hasStaticKeys(environment: environment) + { + return .profile + } + return .keys + } + + static func hasStaticKeys(environment: [String: String]) -> Bool { self.accessKeyID(environment: environment) != nil && self.secretAccessKey(environment: environment) != nil } + public static func hasCredentials( + environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool + { + switch self.authMode(environment: environment) { + case .keys: + self.hasStaticKeys(environment: environment) + case .profile: + self.profile(environment: environment) != nil + } + } + static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift b/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift index b9e81249..f2c44717 100644 --- a/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift @@ -416,8 +416,10 @@ enum BedrockUsageFetcher { } } -public enum BedrockUsageError: LocalizedError, Sendable { +public enum BedrockUsageError: LocalizedError, Sendable, Equatable { case missingCredentials + case awsCLINotFound + case profileSessionExpired(String) case networkError(String) case apiError(String) case parseFailed(String) @@ -427,6 +429,10 @@ public enum BedrockUsageError: LocalizedError, Sendable { case .missingCredentials: "AWS credentials not configured. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY " + "or configure Bedrock in Settings." + case .awsCLINotFound: + "AWS CLI not found. Install the AWS CLI (v2) or set AWS_CLI_PATH to its location." + case let .profileSessionExpired(profile): + "AWS profile session expired. Run `aws sso login --profile \(profile)` and try again." case let .networkError(message): "AWS Bedrock network error: \(message)" case let .apiError(message): diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift index 96ef72ee..93f2ddd8 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeCLISession.swift @@ -300,8 +300,7 @@ actor ClaudeCLISession { let workingDirectory = ClaudeStatusProbe.preparedProbeWorkingDirectoryURL() proc.currentDirectoryURL = workingDirectory - var env = TTYCommandRunner.enrichedEnvironment() - env = Self.scrubbedClaudeEnvironment(from: env) + var env = Self.launchEnvironment() env["PWD"] = workingDirectory.path proc.environment = env @@ -344,6 +343,10 @@ actor ClaudeCLISession { self.startedAt = Date() } + static func launchEnvironment(baseEnv: [String: String] = ProcessInfo.processInfo.environment) -> [String: String] { + self.scrubbedClaudeEnvironment(from: TTYCommandRunner.enrichedEnvironment(baseEnv: baseEnv)) + } + private static func scrubbedClaudeEnvironment(from base: [String: String]) -> [String: String] { var env = base let explicitKeys: [String] = [ diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift index 798fb98d..5f790ee9 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthCredentials.swift @@ -192,17 +192,19 @@ public enum ClaudeOAuthCredentialsStore { Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration, !cachedRecord.credentials.isExpired { - if recovery.shouldAttemptFreshnessSyncFromClaudeKeychain(cached: cachedRecord), + let owner = self.resolvedCacheOwner(cachedRecord.owner) + let record = ClaudeOAuthCredentialRecord( + credentials: cachedRecord.credentials, + owner: owner, + source: .memoryCache) + if recovery.shouldAttemptFreshnessSyncFromClaudeKeychain(cached: record), let synced = recovery.syncWithClaudeKeychainIfChanged( - cached: cachedRecord, + cached: record, respectKeychainPromptCooldown: shouldRespectKeychainPromptCooldownForSilentProbes) { return synced } - return ClaudeOAuthCredentialRecord( - credentials: cachedRecord.credentials, - owner: cachedRecord.owner, - source: .memoryCache) + return record } var lastError: Error? @@ -212,7 +214,7 @@ public enum ClaudeOAuthCredentialsStore { switch KeychainCacheStore.load(key: ClaudeOAuthCredentialsStore.cacheKey, as: CacheEntry.self) { case let .found(entry): if let creds = try? ClaudeOAuthCredentials.parse(data: entry.data) { - let owner = entry.owner ?? .claudeCLI + let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI) let record = ClaudeOAuthCredentialRecord( credentials: creds, owner: owner, @@ -326,9 +328,10 @@ public enum ClaudeOAuthCredentialsStore { Date().timeIntervalSince(timestamp) < ClaudeOAuthCredentialsStore.memoryCacheValidityDuration, !cachedRecord.credentials.isExpired { + let owner = self.resolvedCacheOwner(cachedRecord.owner) return ClaudeOAuthCredentialRecord( credentials: cachedRecord.credentials, - owner: cachedRecord.owner, + owner: owner, source: .memoryCache) } if case let .found(entry) = KeychainCacheStore.load( @@ -337,9 +340,10 @@ public enum ClaudeOAuthCredentialsStore { let creds = try? ClaudeOAuthCredentials.parse(data: entry.data), !creds.isExpired { + let owner = self.resolvedCacheOwner(entry.owner ?? .claudeCLI) return ClaudeOAuthCredentialRecord( credentials: creds, - owner: entry.owner ?? .claudeCLI, + owner: owner, source: .cacheKeychain) } @@ -429,6 +433,18 @@ public enum ClaudeOAuthCredentialsStore { return nil } + private func resolvedCacheOwner(_ owner: ClaudeOAuthCredentialOwner) -> ClaudeOAuthCredentialOwner { + guard owner == .codexbar else { return owner } + guard self.hasClaudeCLIStorageWithoutPrompt() else { return owner } + // Claude Code rotates refresh tokens; when its storage exists, it owns the refresh lifecycle. + return .claudeCLI + } + + private func hasClaudeCLIStorageWithoutPrompt() -> Bool { + if ClaudeOAuthCredentialsStore.currentFileFingerprint() != nil { return true } + return ClaudeOAuthCredentialsStore.hasClaudeKeychainItemWithoutPrompt() + } + @discardableResult func invalidateCacheIfCredentialsFileChanged() -> Bool { self.context.run { @@ -1289,6 +1305,40 @@ public enum ClaudeOAuthCredentialsStore { Repository(context: self.currentCollaboratorContext()).hasClaudeKeychainCredentialsWithoutPrompt() } + private static func hasClaudeKeychainItemWithoutPrompt() -> Bool { + #if DEBUG + if let store = self.taskClaudeKeychainOverrideStore { + if let data = store.data, !data.isEmpty { return true } + if store.fingerprint != nil { return true } + } + if let data = self.taskClaudeKeychainDataOverride ?? self.claudeKeychainDataOverride, + !data.isEmpty + { + return true + } + if self.taskClaudeKeychainFingerprintOverride ?? self.claudeKeychainFingerprintOverride != nil { + return true + } + #endif + + #if os(macOS) + switch self.claudeKeychainCandidatesProbeWithoutPrompt(enforcePromptPolicy: false) { + case let .value(candidates) where !candidates.isEmpty: + return true + case .value, .unavailable: + break + } + switch self.claudeKeychainLegacyCandidateProbeWithoutPrompt(enforcePromptPolicy: false) { + case let .value(candidate): + return candidate != nil + case .unavailable: + return false + } + #else + return false + #endif + } + private static func shouldCheckClaudeKeychainChange(now: Date = Date()) -> Bool { #if DEBUG // Unit tests can supply TaskLocal overrides for the Claude keychain data/fingerprint. Those tests often run @@ -1561,12 +1611,17 @@ public enum ClaudeOAuthCredentialsStore { private static func claudeKeychainCandidatesProbeWithoutPrompt( promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference - .current()) -> ClaudeKeychainProbe<[ClaudeKeychainCandidate]> + .current(), + enforcePromptPolicy: Bool = true) -> ClaudeKeychainProbe<[ClaudeKeychainCandidate]> { - guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } - if self.isPromptPolicyApplicable, - ProviderInteractionContext.current == .background, - !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } + if enforcePromptPolicy { + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } + if self.isPromptPolicyApplicable, + ProviderInteractionContext.current == .background, + !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } + } else { + guard self.keychainAccessAllowed else { return .unavailable } + } var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.claudeKeychainService, @@ -1617,12 +1672,17 @@ public enum ClaudeOAuthCredentialsStore { private static func claudeKeychainLegacyCandidateProbeWithoutPrompt( promptMode: ClaudeOAuthKeychainPromptMode = ClaudeOAuthKeychainPromptPreference - .current()) -> ClaudeKeychainProbe + .current(), + enforcePromptPolicy: Bool = true) -> ClaudeKeychainProbe { - guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } - if self.isPromptPolicyApplicable, - ProviderInteractionContext.current == .background, - !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } + if enforcePromptPolicy { + guard self.shouldAllowClaudeCodeKeychainAccess(mode: promptMode) else { return .unavailable } + if self.isPromptPolicyApplicable, + ProviderInteractionContext.current == .background, + !ClaudeOAuthKeychainAccessGate.shouldAllowPrompt() { return .unavailable } + } else { + guard self.keychainAccessAllowed else { return .unavailable } + } var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: self.claudeKeychainService, diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift index 10b481bf..72b2a4f5 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift @@ -5,6 +5,7 @@ import FoundationNetworking public enum ClaudeOAuthFetchError: LocalizedError, Sendable { case unauthorized + case rateLimited(retryAfter: Date?) case invalidResponse case serverError(Int, String?) case networkError(Error) @@ -13,6 +14,9 @@ public enum ClaudeOAuthFetchError: LocalizedError, Sendable { switch self { case .unauthorized: return "Claude OAuth request unauthorized. Run `claude` to re-authenticate." + case .rateLimited: + return "Claude OAuth usage endpoint is rate limited by Anthropic right now. Wait a few minutes, " + + "then click Refresh. If it keeps happening, run `claude logout && claude login`, then try again." case .invalidResponse: return "Claude OAuth response was invalid." case let .serverError(code, body): @@ -37,6 +41,10 @@ enum ClaudeOAuthUsageFetcher { private static let fallbackClaudeCodeVersion = "2.1.0" static func fetchUsage(accessToken: String) async throws -> OAuthUsageResponse { + if let blockedUntil = ClaudeOAuthUsageRateLimitGate.blockedUntil() { + throw ClaudeOAuthFetchError.rateLimited(retryAfter: blockedUntil) + } + guard let url = URL(string: baseURL + usagePath) else { throw ClaudeOAuthFetchError.invalidResponse } @@ -56,9 +64,16 @@ enum ClaudeOAuthUsageFetcher { let data = response.data switch response.statusCode { case 200: - return try Self.decodeUsageResponse(data) + let usage = try Self.decodeUsageResponse(data) + ClaudeOAuthUsageRateLimitGate.recordSuccess() + return usage case 401: throw ClaudeOAuthFetchError.unauthorized + case 429: + let retryAfter = Self.retryAfterDate(from: response.response) + ClaudeOAuthUsageRateLimitGate.recordRateLimit(retryAfter: retryAfter) + throw ClaudeOAuthFetchError.rateLimited( + retryAfter: ClaudeOAuthUsageRateLimitGate.currentBlockedUntil() ?? retryAfter) case 403: let body = String(data: data, encoding: .utf8) throw ClaudeOAuthFetchError.serverError(response.statusCode, body) @@ -87,6 +102,23 @@ enum ClaudeOAuthUsageFetcher { return formatter.date(from: string) } + private static func retryAfterDate(from response: HTTPURLResponse, now: Date = Date()) -> Date? { + guard let raw = response.value(forHTTPHeaderField: "Retry-After")? + .trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { return nil } + + if let seconds = TimeInterval(raw), seconds >= 0 { + return now.addingTimeInterval(seconds) + } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss zzz" + return formatter.date(from: raw) + } + private static func claudeCodeUserAgent() -> String { self.claudeCodeUserAgent(versionString: ProviderVersionDetector.claudeVersion()) } @@ -112,9 +144,7 @@ struct OAuthUsageResponse: Decodable { let sevenDayOAuthApps: OAuthUsageWindow? let sevenDayOpus: OAuthUsageWindow? let sevenDaySonnet: OAuthUsageWindow? - let sevenDayDesign: OAuthUsageWindow? let sevenDayRoutines: OAuthUsageWindow? - let sevenDayDesignSourceKey: String? let sevenDayRoutinesSourceKey: String? let iguanaNecktie: OAuthUsageWindow? let extraUsage: OAuthExtraUsage? @@ -126,17 +156,6 @@ struct OAuthUsageResponse: Decodable { self.sevenDayOAuthApps = Self.decodeWindow(in: container, keys: ["seven_day_oauth_apps"]) self.sevenDayOpus = Self.decodeWindow(in: container, keys: ["seven_day_opus"]) self.sevenDaySonnet = Self.decodeWindow(in: container, keys: ["seven_day_sonnet"]) - let design = Self.decodeWindowWithSource(in: container, keys: [ - "seven_day_design", - "seven_day_claude_design", - "claude_design", - "design", - "seven_day_omelette", - "omelette", - "omelette_promotional", - ]) - self.sevenDayDesign = design.window - self.sevenDayDesignSourceKey = design.sourceKey let routines = Self.decodeWindowWithSource(in: container, keys: [ "seven_day_routines", "seven_day_claude_routines", @@ -240,5 +259,9 @@ extension ClaudeOAuthUsageFetcher { static func _userAgentForTesting(versionString: String?) -> String { self.claudeCodeUserAgent(versionString: versionString) } + + static func _retryAfterDateForTesting(from response: HTTPURLResponse, now: Date) -> Date? { + self.retryAfterDate(from: response, now: now) + } } #endif diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageRateLimitGate.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageRateLimitGate.swift new file mode 100644 index 00000000..01ee9ec8 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageRateLimitGate.swift @@ -0,0 +1,46 @@ +import Foundation + +enum ClaudeOAuthUsageRateLimitGate { + private static let blockedUntilKey = "claudeOAuthUsageRateLimitBlockedUntilV1" + private static let defaultCooldown: TimeInterval = 60 * 5 + + static func blockedUntil( + interaction: ProviderInteraction = ProviderInteractionContext.current, + now: Date = Date()) -> Date? + { + guard interaction != .userInitiated else { return nil } + return self.currentBlockedUntil(now: now) + } + + static func currentBlockedUntil(now: Date = Date()) -> Date? { + guard let raw = UserDefaults.standard.object(forKey: self.blockedUntilKey) as? Double else { + return nil + } + + let blockedUntil = Date(timeIntervalSince1970: raw) + guard blockedUntil > now else { + UserDefaults.standard.removeObject(forKey: self.blockedUntilKey) + return nil + } + return blockedUntil + } + + static func recordRateLimit(retryAfter: Date?, now: Date = Date()) { + let blockedUntil = if let retryAfter, retryAfter > now { + retryAfter + } else { + now.addingTimeInterval(self.defaultCooldown) + } + UserDefaults.standard.set(blockedUntil.timeIntervalSince1970, forKey: self.blockedUntilKey) + } + + static func recordSuccess() { + UserDefaults.standard.removeObject(forKey: self.blockedUntilKey) + } + + #if DEBUG + static func resetForTesting() { + UserDefaults.standard.removeObject(forKey: self.blockedUntilKey) + } + #endif +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift index ece1da18..333ec400 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeStatusProbe.swift @@ -317,6 +317,7 @@ public struct ClaudeStatusProbe: Sendable { || normalized.contains("currentweek") || normalized.contains("loadingusage") || normalized.contains("failedtoloadusagedata") + || self.usageCaptureHasSubscriptionNotice(normalized) } private static func extractPercent(labelSubstrings: [String], context: LabelSearchContext) -> Int? { @@ -453,6 +454,9 @@ public struct ClaudeStatusProbe: Sendable { { return "Claude CLI usage endpoint is rate limited right now. Please try again later." } + if self.isSubscriptionNoticeOnly(text: text) { + return "Claude CLI /usage returned a subscription notice without session quota data." + } if lower.contains("failed to load usage data") { return "Claude CLI could not load usage data. Open the CLI and retry `/usage`." } @@ -468,6 +472,24 @@ public struct ClaudeStatusProbe: Sendable { return !self.usageCaptureHasSessionValue(normalized) && self.allPercents(text).isEmpty } + /// Returns true when the text contains only a subscription notice with no session/weekly quota data. + /// CLI 2.1+ can return "You are currently using your subscription to power your Claude Code usage" + /// which lacks the "Current session" / "Current week" labels and percentage values needed for quota display. + /// A PTY capture may contain both an intermediate "Loading usage data…" panel and the final subscription + /// notice; `loadingusage` is not treated as quota data in this check so mixed captures surface correctly. + private static func isSubscriptionNoticeOnly(text: String) -> Bool { + let normalized = text.lowercased().filter { !$0.isWhitespace } + guard normalized.contains("currentlyusingyoursubscription") else { return false } + guard normalized.contains("claudecodeusage") else { return false } + // Only real session/week labels and actual percentage values count as quota data. + // `loadingusage` is not quota data — a mixed loading+subscription PTY capture should + // surface the subscription error, not a still-loading stall. + let hasQuotaData = normalized.contains("currentsession") || normalized.contains("currentweek") + || normalized.contains("%used") || normalized.contains("%left") || normalized.contains("%remaining") + || normalized.contains("%available") + return !hasQuotaData + } + /// Collect remaining percentages in the order they appear; used as a backup when labels move/rename. private static func allPercents(_ text: String) -> [Int] { let lines = text.components(separatedBy: .newlines) @@ -871,6 +893,7 @@ public struct ClaudeStatusProbe: Sendable { let stopWhenNormalized: (@Sendable (String) -> Bool)? = subcommand == "/usage" ? { @Sendable normalizedScan in Self.usageCaptureHasSessionValue(normalizedScan) + || Self.usageCaptureHasSubscriptionNotice(normalizedScan) } : nil do { @@ -902,4 +925,9 @@ public struct ClaudeStatusProbe: Sendable { let tail = normalizedText[labelRange.upperBound...] return tail.range(of: #"[0-9]{1,3}(?:\.[0-9]+)?%"#, options: .regularExpression) != nil } + + private static func usageCaptureHasSubscriptionNotice(_ normalizedText: String) -> Bool { + normalizedText.contains("currentlyusingyoursubscription") + && normalizedText.contains("claudecodeusage") + } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 7ce2f879..c7cceae9 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -301,6 +301,9 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } throw ClaudeUsageError.oauthFailed(error.localizedDescription) } catch let error as ClaudeOAuthFetchError { + if case .rateLimited = error { + throw ClaudeUsageError.oauthFailed(error.localizedDescription) + } ClaudeOAuthCredentialsStore.invalidateCache() if case let .serverError(statusCode, body) = error, statusCode == 403, @@ -569,11 +572,50 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { } private func loadViaCLI(model: String, timeout: TimeInterval) async throws -> ClaudeUsageSnapshot { - var snapshot = try await self.fetcher.loadViaPTY(model: model, timeout: timeout) + var snapshot: ClaudeUsageSnapshot + do { + snapshot = try await self.fetcher.loadViaPTY(model: model, timeout: timeout) + } catch { + if error is CancellationError { throw error } + guard Self.shouldTryDirectCLIUsage(after: error) else { throw error } + let ptyError = error + do { + snapshot = try await self.fetcher.loadViaDirectCLI( + timeout: Self.directCLIUsageTimeout(for: timeout)) + } catch let directError { + if directError is CancellationError { throw directError } + guard Self.directCLIErrorShouldReplacePTYError(directError) else { throw ptyError } + throw directError + } + } snapshot = await self.fetcher.applyWebExtrasIfNeeded(to: snapshot) return snapshot } + private static func directCLIUsageTimeout(for ptyTimeout: TimeInterval) -> TimeInterval { + min(max(ptyTimeout / 3, 6), 8) + } + + private static func directCLIErrorShouldReplacePTYError(_ error: Error) -> Bool { + if case let ClaudeStatusProbeError.parseFailed(message) = error { + return message.lowercased().contains("subscription") + } + if case let ClaudeUsageError.parseFailed(message) = error { + return message.lowercased().contains("subscription") + } + return false + } + + private static func shouldTryDirectCLIUsage(after error: Error) -> Bool { + if case ClaudeStatusProbeError.timedOut = error { return true } + if case let ClaudeStatusProbeError.parseFailed(message) = error { + let lower = message.lowercased() + return lower.contains("still loading usage") || lower.contains("could not load usage data") + } + let message = error.localizedDescription.lowercased() + return message.contains("timed out") || message.contains("timeout") + } + private static func shouldRetryCLIProbe(after error: Error) -> Bool { if case ClaudeStatusProbeError.timedOut = error { return true } if case let ClaudeStatusProbeError.parseFailed(message) = error { @@ -822,7 +864,12 @@ extension ClaudeUsageFetcher { for outcome: ClaudeOAuthDelegatedRefreshCoordinator.Outcome, retryError: Error) -> String { - _ = retryError + if let oauthError = retryError as? ClaudeOAuthFetchError, + case .rateLimited = oauthError + { + return oauthError.localizedDescription + } + switch outcome { case .skippedByCooldown: return "Claude OAuth token expired and delegated refresh is cooling down. " @@ -856,6 +903,9 @@ extension ClaudeUsageFetcher { switch oauthError { case .unauthorized: metadata["oauthError"] = "unauthorized" + case let .rateLimited(retryAfter): + metadata["oauthError"] = "rateLimited" + metadata["retryAfter"] = retryAfter.map { "\($0.timeIntervalSince1970)" } ?? "nil" case .invalidResponse: metadata["oauthError"] = "invalidResponse" case let .serverError(statusCode, body): @@ -954,7 +1004,7 @@ extension ClaudeUsageFetcher { let normalized = Self.normalizeClaudeExtraUsageAmounts( used: used, limit: limit, - treatAsMajorUnits: isSpendLimit) + treatAsMajorUnits: false) return ProviderCostSnapshot( used: normalized.used, limit: normalized.limit, @@ -998,20 +1048,12 @@ extension ClaudeUsageFetcher { private static func oauthExtraRateWindows(from usage: OAuthUsageResponse) -> [NamedRateWindow] { let definitions: [(id: String, title: String, window: OAuthUsageWindow?, sourceKey: String?)] = [ - ( - id: "claude-design", - title: "Designs", - window: usage.sevenDayDesign, - sourceKey: usage.sevenDayDesignSourceKey), ( id: "claude-routines", title: "Daily Routines", window: usage.sevenDayRoutines, sourceKey: usage.sevenDayRoutinesSourceKey), ] - if let designKey = usage.sevenDayDesignSourceKey { - Self.log.debug("Claude OAuth extra usage key matched: design=\(designKey)") - } if let routinesKey = usage.sevenDayRoutinesSourceKey { Self.log.debug("Claude OAuth extra usage key matched: routines=\(routinesKey)") } @@ -1114,6 +1156,31 @@ extension ClaudeUsageFetcher { keepCLISessionsAlive: self.keepCLISessionsAlive) let snap = try await probe.fetch() + return try Self.makeSnapshot(from: snap) + } + + private func loadViaDirectCLI(timeout: TimeInterval) async throws -> ClaudeUsageSnapshot { + guard let claudeBinary = ClaudeCLIResolver.resolvedBinaryPath(environment: self.environment) else { + throw ClaudeUsageError.claudeNotInstalled + } + + let workingDirectory = ClaudeStatusProbe.preparedProbeWorkingDirectoryURL() + var environment = ClaudeCLISession.launchEnvironment(baseEnv: self.environment) + environment["PWD"] = workingDirectory.path + + let result = try await SubprocessRunner.run( + binary: claudeBinary, + arguments: ["/usage"], + environment: environment, + timeout: timeout, + standardInput: FileHandle.nullDevice, + currentDirectoryURL: workingDirectory, + label: "claude-direct-usage") + let snap = try ClaudeStatusProbe.parse(text: result.stdout) + return try Self.makeSnapshot(from: snap) + } + + private static func makeSnapshot(from snap: ClaudeStatusSnapshot) throws -> ClaudeUsageSnapshot { guard let sessionPctLeft = snap.sessionPercentLeft else { throw ClaudeUsageError.parseFailed("missing session data") } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift index d88505db..61c57332 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift @@ -515,9 +515,6 @@ public enum ClaudeWebAPIFetcher { opusPercent = Self.percentValue(from: sevenDayOpus["utilization"]) } let extraRateParse = ClaudeWebExtraRateWindowParser.parse(from: json) - if let sourceKey = extraRateParse.sourceKeys["claude-design"] { - logger?("Usage API extra window key matched: design=\(sourceKey)") - } if let sourceKey = extraRateParse.sourceKeys["claude-routines"] { logger?("Usage API extra window key matched: routines=\(sourceKey)") } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift index b1df6343..e9be31ee 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeWeb/ClaudeWebExtraRateWindowParser.swift @@ -2,18 +2,6 @@ import Foundation enum ClaudeWebExtraRateWindowParser { private static let definitions: [(id: String, title: String, keys: [String])] = [ - ( - id: "claude-design", - title: "Designs", - keys: [ - "seven_day_design", - "seven_day_claude_design", - "claude_design", - "design", - "seven_day_omelette", - "omelette", - "omelette_promotional", - ]), ( id: "claude-routines", title: "Daily Routines", diff --git a/Sources/CodexBarCore/Providers/Codex/CodexAdditionalRateLimitMapper.swift b/Sources/CodexBarCore/Providers/Codex/CodexAdditionalRateLimitMapper.swift new file mode 100644 index 00000000..6a0997de --- /dev/null +++ b/Sources/CodexBarCore/Providers/Codex/CodexAdditionalRateLimitMapper.swift @@ -0,0 +1,153 @@ +import Foundation + +/// Maps Codex `additional_rate_limits` entries (model-specific limits such as GPT-5.3-Codex-Spark) +/// into named extra rate windows. +/// +/// These limits are reported by the `wham/usage` API alongside, but separately from, the primary and +/// weekly Codex windows, so we surface them through `UsageSnapshot.extraRateWindows` rather than the core +/// primary/secondary lanes. When the field is absent the mapper returns an empty list and the snapshot is +/// unchanged. +enum CodexAdditionalRateLimitMapper { + /// Stable ids/titles for GPT-5.3-Codex-Spark limits so SwiftUI identity stays constant even if the API + /// label wording shifts. Keep the original 5-hour id for compatibility with the first Spark implementation. + static let sparkWindowID = "codex-spark" + static let sparkWeeklyWindowID = "codex-spark-weekly" + static let sparkWindowTitle = "Codex Spark 5-hour" + static let sparkWeeklyWindowTitle = "Codex Spark Weekly" + + static func extraRateWindows( + from additionalRateLimits: [CodexUsageResponse.AdditionalRateLimit]?, + now: Date = Date()) -> [NamedRateWindow] + { + guard let additionalRateLimits, !additionalRateLimits.isEmpty else { return [] } + var usedIDs = Set() + return additionalRateLimits.flatMap { entry in + self.namedWindows(from: entry, usedIDs: &usedIDs, now: now) + } + } + + private static func namedWindows( + from entry: CodexUsageResponse.AdditionalRateLimit, + usedIDs: inout Set, + now: Date) -> [NamedRateWindow] + { + if self.isSpark(entry) { + return self.sparkWindows(from: entry, usedIDs: &usedIDs, now: now) + } + + // Model-specific limits report utilization in the primary window; fall back to the secondary + // window only when a primary one is not present. + guard let snapshot = entry.rateLimit?.primaryWindow ?? entry.rateLimit?.secondaryWindow else { + return [] + } + guard let id = self.windowID(for: entry), usedIDs.insert(id).inserted else { return [] } + return [self.namedWindow( + id: id, + title: self.windowTitle(for: entry), + snapshot: snapshot, + now: now)] + } + + private static func sparkWindows( + from entry: CodexUsageResponse.AdditionalRateLimit, + usedIDs: inout Set, + now: Date) -> [NamedRateWindow] + { + let candidates: [(CodexUsageResponse.WindowSnapshot?, SparkWindowKind)] = [ + (entry.rateLimit?.primaryWindow, .fiveHour), + (entry.rateLimit?.secondaryWindow, .weekly), + ] + + return candidates.compactMap { snapshot, fallbackKind in + guard let snapshot else { return nil } + let kind = self.sparkWindowKind(for: snapshot, fallback: fallbackKind) + guard usedIDs.insert(kind.id).inserted else { return nil } + return self.namedWindow(id: kind.id, title: kind.title, snapshot: snapshot, now: now) + } + } + + private static func namedWindow( + id: String, + title: String, + snapshot: CodexUsageResponse.WindowSnapshot, + now: Date) -> NamedRateWindow + { + let resetDate: Date? = snapshot.resetAt > 0 + ? Date(timeIntervalSince1970: TimeInterval(snapshot.resetAt)) + : nil + let window = RateWindow( + usedPercent: Double(snapshot.usedPercent), + windowMinutes: snapshot.limitWindowSeconds > 0 ? snapshot.limitWindowSeconds / 60 : nil, + resetsAt: resetDate, + resetDescription: resetDate.map { UsageFormatter.resetDescription(from: $0, now: now) }) + return NamedRateWindow(id: id, title: title, window: window) + } + + private enum SparkWindowKind { + case fiveHour + case weekly + + var id: String { + switch self { + case .fiveHour: CodexAdditionalRateLimitMapper.sparkWindowID + case .weekly: CodexAdditionalRateLimitMapper.sparkWeeklyWindowID + } + } + + var title: String { + switch self { + case .fiveHour: CodexAdditionalRateLimitMapper.sparkWindowTitle + case .weekly: CodexAdditionalRateLimitMapper.sparkWeeklyWindowTitle + } + } + } + + private static func sparkWindowKind( + for snapshot: CodexUsageResponse.WindowSnapshot, + fallback: SparkWindowKind) -> SparkWindowKind + { + let minutes = snapshot.limitWindowSeconds > 0 ? snapshot.limitWindowSeconds / 60 : 0 + if minutes > 0, minutes <= 6 * 60 { return .fiveHour } + if minutes >= 6 * 24 * 60 { return .weekly } + return fallback + } + + private static func windowID(for entry: CodexUsageResponse.AdditionalRateLimit) -> String? { + guard let source = self.firstNonEmpty(entry.meteredFeature, entry.limitName) else { return nil } + let slug = self.slug(source) + return slug.isEmpty ? nil : "codex-\(slug)" + } + + private static func windowTitle(for entry: CodexUsageResponse.AdditionalRateLimit) -> String { + self.firstNonEmpty(entry.limitName, entry.meteredFeature) ?? "Codex extra limit" + } + + private static func isSpark(_ entry: CodexUsageResponse.AdditionalRateLimit) -> Bool { + [entry.limitName, entry.meteredFeature] + .compactMap { $0?.lowercased() } + .contains { $0.contains("spark") } + } + + private static func firstNonEmpty(_ values: String?...) -> String? { + for value in values { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + if let trimmed, !trimmed.isEmpty { return trimmed } + } + return nil + } + + private static func slug(_ value: String) -> String { + var result = "" + var lastWasDash = false + for scalar in value.lowercased().unicodeScalars { + if CharacterSet.alphanumerics.contains(scalar) { + result.unicodeScalars.append(scalar) + lastWasDash = false + } else if !lastWasDash { + result.append("-") + lastWasDash = true + } + } + return result.trimmingCharacters(in: CharacterSet(charactersIn: "-")) + } +} diff --git a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift index 71455d98..41f11cef 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift @@ -7,11 +7,14 @@ public struct CodexUsageResponse: Decodable, Sendable { public let planType: PlanType? public let rateLimit: RateLimitDetails? public let credits: CreditDetails? + /// Model-specific limits (e.g. GPT-5.3-Codex-Spark) that sit alongside the primary/weekly windows. + public let additionalRateLimits: [AdditionalRateLimit]? enum CodingKeys: String, CodingKey { case planType = "plan_type" case rateLimit = "rate_limit" case credits + case additionalRateLimits = "additional_rate_limits" } public init(from decoder: Decoder) throws { @@ -19,6 +22,13 @@ public struct CodexUsageResponse: Decodable, Sendable { self.planType = try? container.decodeIfPresent(PlanType.self, forKey: .planType) self.rateLimit = try? container.decodeIfPresent(RateLimitDetails.self, forKey: .rateLimit) self.credits = try? container.decodeIfPresent(CreditDetails.self, forKey: .credits) + // Optional and additive: missing/malformed extra limits must never disturb primary/weekly mapping. + // Decode per element so a single malformed entry cannot discard its valid siblings; a non-array + // value (or absent field) leaves `additionalRateLimits` nil and primary/weekly mapping untouched. + let additionalRateLimits = try? container.decodeIfPresent( + [LossyAdditionalRateLimit].self, + forKey: .additionalRateLimits) + self.additionalRateLimits = additionalRateLimits?.compactMap(\.value) } public enum PlanType: Sendable, Decodable, Equatable { @@ -136,6 +146,38 @@ public struct CodexUsageResponse: Decodable, Sendable { } } + /// One entry of `additional_rate_limits`: a named, model-specific limit (e.g. GPT-5.3-Codex-Spark) + /// whose windows reuse the same shape as the primary/weekly `RateLimitDetails`. + public struct AdditionalRateLimit: Decodable, Sendable { + public let limitName: String? + public let meteredFeature: String? + public let rateLimit: RateLimitDetails? + + enum CodingKeys: String, CodingKey { + case limitName = "limit_name" + case meteredFeature = "metered_feature" + case rateLimit = "rate_limit" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.limitName = try? container.decodeIfPresent(String.self, forKey: .limitName) + self.meteredFeature = try? container.decodeIfPresent(String.self, forKey: .meteredFeature) + self.rateLimit = try? container.decodeIfPresent(RateLimitDetails.self, forKey: .rateLimit) + } + } + + /// Decodes a single `additional_rate_limits` element without ever throwing, so one malformed + /// entry cannot discard its valid siblings during array decoding. + private struct LossyAdditionalRateLimit: Decodable { + let value: AdditionalRateLimit? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.value = try? container.decode(AdditionalRateLimit.self) + } + } + public struct CreditDetails: Decodable, Sendable { public let hasCredits: Bool public let unlimited: Bool diff --git a/Sources/CodexBarCore/Providers/Codex/CodexReconciledState.swift b/Sources/CodexBarCore/Providers/Codex/CodexReconciledState.swift index 2a7d16bf..83f8f28d 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexReconciledState.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexReconciledState.swift @@ -3,17 +3,21 @@ import Foundation public struct CodexReconciledState: Sendable { public let session: RateWindow? public let weekly: RateWindow? + /// Named model-specific limits (e.g. Codex Spark) surfaced through `UsageSnapshot.extraRateWindows`. + public let extraRateWindows: [NamedRateWindow] public let identity: ProviderIdentitySnapshot? public let updatedAt: Date public init( session: RateWindow?, weekly: RateWindow?, + extraRateWindows: [NamedRateWindow] = [], identity: ProviderIdentitySnapshot?, updatedAt: Date) { self.session = session self.weekly = weekly + self.extraRateWindows = extraRateWindows self.identity = identity self.updatedAt = updatedAt } @@ -35,6 +39,9 @@ public struct CodexReconciledState: Sendable { self.make( primary: self.makeWindow(response.rateLimit?.primaryWindow), secondary: self.makeWindow(response.rateLimit?.secondaryWindow), + extraRateWindows: CodexAdditionalRateLimitMapper.extraRateWindows( + from: response.additionalRateLimits, + now: updatedAt), identity: self.oauthIdentity(response: response, credentials: credentials), updatedAt: updatedAt) } @@ -56,6 +63,7 @@ public struct CodexReconciledState: Sendable { return self.make( primary: snapshot.primaryLimit, secondary: snapshot.secondaryLimit, + extraRateWindows: snapshot.extraRateWindows ?? [], identity: identity, updatedAt: snapshot.updatedAt) } @@ -65,6 +73,7 @@ public struct CodexReconciledState: Sendable { primary: self.session, secondary: self.weekly, tertiary: nil, + extraRateWindows: self.extraRateWindows.isEmpty ? nil : self.extraRateWindows, updatedAt: self.updatedAt, identity: self.identity) } @@ -83,10 +92,13 @@ public struct CodexReconciledState: Sendable { private static func make( primary: RateWindow?, secondary: RateWindow?, + extraRateWindows: [NamedRateWindow] = [], identity: ProviderIdentitySnapshot?, updatedAt: Date) -> CodexReconciledState? { let normalized = CodexRateWindowNormalizer.normalize(primary: primary, secondary: secondary) + // Extra windows are supplemental, so they never resurrect a snapshot on their own: keep the + // existing primary/weekly gate to preserve current behavior when only extra limits are present. guard normalized.primary != nil || normalized.secondary != nil else { return nil } @@ -94,6 +106,7 @@ public struct CodexReconciledState: Sendable { return CodexReconciledState( session: normalized.primary, weekly: normalized.secondary, + extraRateWindows: extraRateWindows, identity: identity, updatedAt: updatedAt) } diff --git a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift index b613a54e..5b21d120 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexWebDashboardStrategy.swift @@ -217,10 +217,15 @@ extension CodexWebDashboardStrategy { switch decision.disposition { case .attach: let attachedAccountEmail = CodexCLIDashboardAuthorityContext.attachmentEmail(from: input) - guard let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: attachedAccountEmail) else { + let credits = dashboard.toCreditsSnapshot() + let usage = dashboard.toUsageSnapshot(provider: .codex, accountEmail: attachedAccountEmail) + ?? Self.makeCreditsOnlyUsageSnapshot( + dashboard: dashboard, + attachedAccountEmail: attachedAccountEmail, + credits: credits) + guard let usage else { throw OpenAIWebCodexError.missingUsage } - let credits = dashboard.toCreditsSnapshot() if let attachedAccountEmail { OpenAIDashboardCacheStore.save(OpenAIDashboardCache( accountEmail: attachedAccountEmail, @@ -240,6 +245,24 @@ extension CodexWebDashboardStrategy { } } + private static func makeCreditsOnlyUsageSnapshot( + dashboard: OpenAIDashboardSnapshot, + attachedAccountEmail: String?, + credits: CreditsSnapshot?) -> UsageSnapshot? + { + guard credits != nil else { return nil } + return UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + updatedAt: dashboard.updatedAt, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: attachedAccountEmail ?? dashboard.signedInEmail, + accountOrganization: nil, + loginMethod: dashboard.accountPlan)) + } + private struct OpenAIWebDashboardFetchResult { let dashboard: OpenAIDashboardSnapshot let routingTargetEmail: String? diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift index 56db2288..fec26d57 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift @@ -54,7 +54,10 @@ struct DeepSeekAPIFetchStrategy: ProviderFetchStrategy { guard let apiKey = Self.resolveToken(environment: context.env) else { throw DeepSeekUsageError.missingCredentials } - let usage = try await DeepSeekUsageFetcher.fetchUsage(apiKey: apiKey) + let usage = try await DeepSeekUsageFetcher.fetchUsage( + apiKey: apiKey, + includeOptionalUsage: context.includeOptionalUsage) + return self.makeResult( usage: usage.toUsageSnapshot(), sourceLabel: "api") diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift new file mode 100644 index 00000000..77e6e4f2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageCostParser.swift @@ -0,0 +1,709 @@ +import Foundation + +// MARK: - Amount Response Models + +struct DeepSeekAmountPayload: Decodable { + let code: Int? + let msg: String? + let data: DeepSeekAmountData? + + private enum CodingKeys: String, CodingKey { + case code, msg, data + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.code = try container.decodeIfPresent(Int.self, forKey: .code) + self.msg = try container.decodeIfPresent(String.self, forKey: .msg) + if container.contains(.data) { + if let dataValue = try? container.decodeIfPresent(DeepSeekAmountData.self, forKey: .data) { + self.data = dataValue + } else { + self.data = nil + } + } else { + self.data = nil + } + } +} + +struct DeepSeekAmountData: Decodable { + let bizCode: Int? + let bizMsg: String? + let bizData: DeepSeekAmountBizData? + + private enum CodingKeys: String, CodingKey { + case bizCode = "biz_code" + case bizMsg = "biz_msg" + case bizData = "biz_data" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.bizCode = try container.decodeIfPresent(Int.self, forKey: .bizCode) + self.bizMsg = try container.decodeIfPresent(String.self, forKey: .bizMsg) + if container.contains(.bizData) { + if let dataValue = try? container.decodeIfPresent(DeepSeekAmountBizData.self, forKey: .bizData) { + self.bizData = dataValue + } else { + self.bizData = nil + } + } else { + self.bizData = nil + } + } +} + +struct DeepSeekAmountBizData: Decodable { + let total: [DeepSeekModelUsage]? + let days: [DeepSeekDayUsage]? + + private enum CodingKeys: String, CodingKey { + case total, days + } +} + +// MARK: - Cost Response Models + +struct DeepSeekCostPayload: Decodable { + let code: Int? + let msg: String? + let data: DeepSeekCostData? + + private enum CodingKeys: String, CodingKey { + case code, msg, data + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.code = try container.decodeIfPresent(Int.self, forKey: .code) + self.msg = try container.decodeIfPresent(String.self, forKey: .msg) + if container.contains(.data) { + if let dataValue = try? container.decodeIfPresent(DeepSeekCostData.self, forKey: .data) { + self.data = dataValue + } else { + self.data = nil + } + } else { + self.data = nil + } + } +} + +struct DeepSeekCostData: Decodable { + let bizCode: Int? + let bizMsg: String? + let bizData: [DeepSeekCostBizDataItem]? + + private enum CodingKeys: String, CodingKey { + case bizCode = "biz_code" + case bizMsg = "biz_msg" + case bizData = "biz_data" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.bizCode = try container.decodeIfPresent(Int.self, forKey: .bizCode) + self.bizMsg = try container.decodeIfPresent(String.self, forKey: .bizMsg) + self.bizData = try container.decodeIfPresent([DeepSeekCostBizDataItem].self, forKey: .bizData) + } +} + +struct DeepSeekCostBizDataItem: Decodable { + let total: [DeepSeekCostModelUsage]? + let days: [DeepSeekCostDayUsage]? + let currency: String? + + private enum CodingKeys: String, CodingKey { + case total, days, currency + } +} + +// MARK: - Shared Models + +struct DeepSeekModelUsage: Decodable { + let model: String? + let usage: [DeepSeekUsageItem]? + + private enum CodingKeys: String, CodingKey { + case model, usage + } +} + +struct DeepSeekDayUsage: Decodable { + let date: String? + let data: [DeepSeekModelUsage]? + + private enum CodingKeys: String, CodingKey { + case date, data + } +} + +struct DeepSeekUsageItem: Decodable { + let type: String? + let amount: String? + + private enum CodingKeys: String, CodingKey { + case type, amount + } +} + +struct DeepSeekCostModelUsage: Decodable { + let model: String? + let usage: [DeepSeekCostItem]? + + private enum CodingKeys: String, CodingKey { + case model, usage + } +} + +struct DeepSeekCostDayUsage: Decodable { + let date: String? + let data: [DeepSeekCostModelUsage]? + + private enum CodingKeys: String, CodingKey { + case date, data + } +} + +struct DeepSeekCostItem: Decodable { + let type: String? + let amount: String? + + private enum CodingKeys: String, CodingKey { + case type, amount + } +} + +// MARK: - Domain Models + +public struct DeepSeekUsageSummary: Sendable, Equatable { + public let todayTokens: Int + public let currentMonthTokens: Int + public let todayCost: Double? + public let currentMonthCost: Double? + public let requestCount: Int + public let currentMonthRequestCount: Int + public let topModel: String? + public let categoryBreakdown: [DeepSeekCategoryBreakdown] + public let daily: [DeepSeekDailyUsage] + public let currency: String + public let updatedAt: Date + + public init( + todayTokens: Int, + currentMonthTokens: Int, + todayCost: Double?, + currentMonthCost: Double?, + requestCount: Int, + currentMonthRequestCount: Int, + topModel: String?, + categoryBreakdown: [DeepSeekCategoryBreakdown], + daily: [DeepSeekDailyUsage], + currency: String, + updatedAt: Date) + { + self.todayTokens = todayTokens + self.currentMonthTokens = currentMonthTokens + self.todayCost = todayCost + self.currentMonthCost = currentMonthCost + self.requestCount = requestCount + self.currentMonthRequestCount = currentMonthRequestCount + self.topModel = topModel + self.categoryBreakdown = categoryBreakdown + self.daily = daily + self.currency = currency + self.updatedAt = updatedAt + } +} + +public struct DeepSeekCategoryBreakdown: Sendable, Equatable { + public let category: DeepSeekUsageCategory + public let tokens: Int + public let cost: Double? + + public init(category: DeepSeekUsageCategory, tokens: Int, cost: Double?) { + self.category = category + self.tokens = tokens + self.cost = cost + } +} + +public enum DeepSeekUsageCategory: String, Sendable, Equatable { + case promptCacheHitToken = "PROMPT_CACHE_HIT_TOKEN" + case promptCacheMissToken = "PROMPT_CACHE_MISS_TOKEN" + case responseToken = "RESPONSE_TOKEN" + case request = "REQUEST" + + public init?(rawValue: String) { + switch rawValue.uppercased() { + case "PROMPT_CACHE_HIT_TOKEN": + self = .promptCacheHitToken + case "PROMPT_CACHE_MISS_TOKEN": + self = .promptCacheMissToken + case "RESPONSE_TOKEN": + self = .responseToken + case "REQUEST": + self = .request + default: + return nil + } + } +} + +public struct DeepSeekDailyUsage: Sendable, Equatable { + public let date: String + public let totalTokens: Int + public let cost: Double? + public let requestCount: Int + + public init(date: String, totalTokens: Int, cost: Double?, requestCount: Int) { + self.date = date + self.totalTokens = totalTokens + self.cost = cost + self.requestCount = requestCount + } +} + +// MARK: - Parsing + +enum DeepSeekUsageCostParser { + static func decodeAmountPayload(data: Data) throws -> DeepSeekAmountPayload { + try JSONDecoder().decode(DeepSeekAmountPayload.self, from: data) + } + + static func decodeCostPayload(data: Data) throws -> DeepSeekCostPayload { + try JSONDecoder().decode(DeepSeekCostPayload.self, from: data) + } + + static func parse( + amountData: Data, + costData: Data, + now: Date = Date(), + calendar: Calendar = .current) throws -> DeepSeekUsageSummary + { + let amountPayload: DeepSeekAmountPayload + let costPayload: DeepSeekCostPayload + do { + amountPayload = try self.decodeAmountPayload(data: amountData) + } catch { + throw DeepSeekUsageError.parseFailed("amount: \(error.localizedDescription)") + } + do { + costPayload = try self.decodeCostPayload(data: costData) + } catch { + throw DeepSeekUsageError.parseFailed("cost: \(error.localizedDescription)") + } + + // Validate responses + if let code = amountPayload.code, code != 0 { + throw DeepSeekUsageError.apiError("amount code \(code)") + } + if let bizCode = amountPayload.data?.bizCode, bizCode != 0 { + throw DeepSeekUsageError.apiError("amount biz_code \(bizCode)") + } + if let code = costPayload.code, code != 0 { + throw DeepSeekUsageError.apiError("cost code \(code)") + } + if let bizCode = costPayload.data?.bizCode, bizCode != 0 { + throw DeepSeekUsageError.apiError("cost biz_code \(bizCode)") + } + + guard let amountBizData = amountPayload.data?.bizData else { + throw DeepSeekUsageError.parseFailed("Missing amount biz_data") + } + + let currency = costPayload.data?.bizData?.first?.currency ?? "CNY" + + // Parse total amounts + let totalAmounts = amountBizData.total ?? [] + let totalCosts = costPayload.data?.bizData?.first?.total ?? [] + + // Parse daily data + let dailyAmounts = amountBizData.days ?? [] + let dailyCosts = costPayload.data?.bizData?.first?.days ?? [] + + return self.aggregate(input: AggregationInput( + totalAmounts: totalAmounts, + totalCosts: totalCosts, + dailyAmounts: dailyAmounts, + dailyCosts: dailyCosts, + currency: currency, + now: now, + calendar: calendar)) + } + + // MARK: - Aggregation + + private struct AggregationContext { + let calendar: Calendar + let todayString: String + let startOfMonth: Date + let now: Date + let dailyAmountMap: [String: [String: [DeepSeekUsageItem]]] + let dailyCostMap: [String: [String: [DeepSeekCostItem]]] + let allDates: Set + + init( + dailyAmounts: [DeepSeekDayUsage], + dailyCosts: [DeepSeekCostDayUsage], + now: Date, + calendar: Calendar) + { + self.calendar = calendar + self.now = now + self.todayString = Self.dayString(now, calendar: calendar) + + var components = calendar.dateComponents([.year, .month], from: now) + components.day = 1 + self.startOfMonth = calendar.date(from: components) ?? now + + self.dailyAmountMap = Self.buildAmountMap(from: dailyAmounts) + self.dailyCostMap = Self.buildCostMap(from: dailyCosts) + + var dates: Set = [] + for date in self.dailyAmountMap.keys { + dates.insert(date) + } + for date in self.dailyCostMap.keys { + dates.insert(date) + } + self.allDates = dates + } + + static func dayString(_ date: Date, calendar: Calendar) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + guard let year = components.year, + let month = components.month, + let day = components.day + else { return "" } + return String(format: "%04d-%02d-%02d", year, month, day) + } + + static func buildAmountMap( + from dailyAmounts: [DeepSeekDayUsage]) -> [String: [String: [DeepSeekUsageItem]]] + { + var result: [String: [String: [DeepSeekUsageItem]]] = [:] + for dayUsage in dailyAmounts { + guard let date = dayUsage.date else { continue } + var modelMap: [String: [DeepSeekUsageItem]] = [:] + for modelUsage in dayUsage.data ?? [] { + guard let model = modelUsage.model else { continue } + let items = modelUsage.usage ?? [] + if !items.isEmpty { + modelMap[model] = items + } + } + if !modelMap.isEmpty { + result[date] = modelMap + } + } + return result + } + + static func buildCostMap( + from dailyCosts: [DeepSeekCostDayUsage]) -> [String: [String: [DeepSeekCostItem]]] + { + var result: [String: [String: [DeepSeekCostItem]]] = [:] + for dayUsage in dailyCosts { + guard let date = dayUsage.date else { continue } + var modelMap: [String: [DeepSeekCostItem]] = [:] + for modelUsage in dayUsage.data ?? [] { + guard let model = modelUsage.model else { continue } + let items = modelUsage.usage ?? [] + if !items.isEmpty { + modelMap[model] = items + } + } + if !modelMap.isEmpty { + result[date] = modelMap + } + } + return result + } + } + + private struct AggregationInput { + let totalAmounts: [DeepSeekModelUsage] + let totalCosts: [DeepSeekCostModelUsage] + let dailyAmounts: [DeepSeekDayUsage] + let dailyCosts: [DeepSeekCostDayUsage] + let currency: String + let now: Date + let calendar: Calendar + } + + private static func aggregate(input: AggregationInput) -> DeepSeekUsageSummary { + let ctx = AggregationContext( + dailyAmounts: input.dailyAmounts, + dailyCosts: input.dailyCosts, + now: input.now, + calendar: input.calendar) + + // Today aggregation + let todayResult = self.aggregateDay( + dateString: ctx.todayString, + amountMap: ctx.dailyAmountMap, + costMap: ctx.dailyCostMap, + calendar: ctx.calendar) + + // Month aggregation + let dailyCtx = DailyAggregationContext( + allDates: ctx.allDates, + startOfMonth: ctx.startOfMonth, + now: ctx.now, + amountMap: ctx.dailyAmountMap, + costMap: ctx.dailyCostMap, + calendar: ctx.calendar) + let monthResult = self.aggregateMonth(ctx: dailyCtx) + + // Model and category breakdown from totals + let (topModel, categoryBreakdown) = self.buildBreakdowns( + totalAmounts: input.totalAmounts, + totalCosts: input.totalCosts) + + // Daily usage array + let dailyUsages = self.buildDailyUsages(ctx: dailyCtx) + + return DeepSeekUsageSummary( + todayTokens: todayResult.tokens, + currentMonthTokens: monthResult.tokens, + todayCost: todayResult.cost, + currentMonthCost: monthResult.cost, + requestCount: todayResult.requests, + currentMonthRequestCount: monthResult.requests, + topModel: topModel, + categoryBreakdown: categoryBreakdown, + daily: dailyUsages, + currency: input.currency, + updatedAt: input.now) + } + + private struct DayAggregationResult { + let tokens: Int + let cost: Double? + let requests: Int + } + + private struct DailyAggregationContext { + let allDates: Set + let startOfMonth: Date + let now: Date + let amountMap: [String: [String: [DeepSeekUsageItem]]] + let costMap: [String: [String: [DeepSeekCostItem]]] + let calendar: Calendar + } + + private static func aggregateDay( + dateString: String, + amountMap: [String: [String: [DeepSeekUsageItem]]], + costMap: [String: [String: [DeepSeekCostItem]]], + calendar: Calendar) -> DayAggregationResult + { + var tokens = 0 + var cost: Double? + var requests = 0 + + if let amounts = amountMap[dateString] { + for items in amounts.values { + for item in items { + guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue } + if category == .request { + requests += self.parseTokenAmount(item.amount) + } else { + tokens += self.parseTokenAmount(item.amount) + } + } + } + } + + if let costs = costMap[dateString] { + for items in costs.values { + for item in items { + guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue } + if category != .request { + let amount = Self.parseCostAmount(item.amount) + if let existing = cost { + cost = existing + amount + } else { + cost = amount + } + } + } + } + } + + return DayAggregationResult(tokens: tokens, cost: cost, requests: requests) + } + + private static func aggregateMonth(ctx: DailyAggregationContext) -> DayAggregationResult { + var tokens = 0 + var cost: Double? + var requests = 0 + + for date in ctx.allDates { + guard let parsed = self.parseDate(date, calendar: ctx.calendar), + parsed >= ctx.startOfMonth, + parsed <= ctx.now + else { continue } + + if let amounts = ctx.amountMap[date] { + for items in amounts.values { + for item in items { + guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue } + if category == .request { + requests += Self.parseTokenAmount(item.amount) + } else { + tokens += Self.parseTokenAmount(item.amount) + } + } + } + } + + if let costs = ctx.costMap[date] { + for items in costs.values { + for item in items { + guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue } + if category != .request { + let amount = Self.parseCostAmount(item.amount) + if let existing = cost { + cost = existing + amount + } else { + cost = amount + } + } + } + } + } + } + + return DayAggregationResult(tokens: tokens, cost: cost, requests: requests) + } + + private static func buildBreakdowns( + totalAmounts: [DeepSeekModelUsage], + totalCosts: [DeepSeekCostModelUsage]) -> (String?, [DeepSeekCategoryBreakdown]) + { + var modelTokens: [String: Int] = [:] + var categoryTokens: [DeepSeekUsageCategory: Int] = [:] + var categoryCosts: [DeepSeekUsageCategory: Double] = [:] + + for modelUsage in totalAmounts { + guard let model = modelUsage.model else { continue } + var total = 0 + for item in modelUsage.usage ?? [] { + guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue } + if category != .request { + let amount = Self.parseTokenAmount(item.amount) + total += amount + categoryTokens[category, default: 0] += amount + } + } + modelTokens[model] = total + } + + for costUsage in totalCosts { + guard costUsage.model != nil else { continue } + for item in costUsage.usage ?? [] { + guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue } + if category != .request { + let amount = Self.parseCostAmount(item.amount) + categoryCosts[category, default: 0] += amount + } + } + } + + let topModel = modelTokens.max { + if $0.value == $1.value { return $0.key > $1.key } + return $0.value < $1.value + }?.key + + var breakdown: [DeepSeekCategoryBreakdown] = [] + for category in [DeepSeekUsageCategory.promptCacheHitToken, .promptCacheMissToken, .responseToken] { + breakdown.append(DeepSeekCategoryBreakdown( + category: category, + tokens: categoryTokens[category] ?? 0, + cost: categoryCosts[category])) + } + + return (topModel, breakdown) + } + + private static func buildDailyUsages(ctx: DailyAggregationContext) -> [DeepSeekDailyUsage] { + var result: [DeepSeekDailyUsage] = [] + + for date in ctx.allDates.sorted() { + guard let parsed = self.parseDate(date, calendar: ctx.calendar), + parsed >= ctx.startOfMonth, + parsed <= ctx.now + else { continue } + + var dayTokens = 0 + var dayCost: Double? + var dayRequests = 0 + + if let amounts = ctx.amountMap[date] { + for items in amounts.values { + for item in items { + guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue } + if category == .request { + dayRequests += Self.parseTokenAmount(item.amount) + } else { + dayTokens += Self.parseTokenAmount(item.amount) + } + } + } + } + + if let costs = ctx.costMap[date] { + for items in costs.values { + for item in items { + guard let category = DeepSeekUsageCategory(rawValue: item.type ?? "") else { continue } + if category != .request { + let amount = Self.parseCostAmount(item.amount) + if let existing = dayCost { + dayCost = existing + amount + } else { + dayCost = amount + } + } + } + } + } + + result.append(DeepSeekDailyUsage( + date: date, + totalTokens: dayTokens, + cost: dayCost, + requestCount: dayRequests)) + } + + return result + } + + // MARK: - Helpers + + private static func parseTokenAmount(_ value: String?) -> Int { + guard let value, let intValue = Int64(value.trimmingCharacters(in: .whitespacesAndNewlines)) else { + return 0 + } + return Int(intValue) + } + + private static func parseCostAmount(_ value: String?) -> Double { + guard let value else { return 0 } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return Double(trimmed) ?? 0 + } + + private static func parseDate(_ text: String, calendar: Calendar) -> Date? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = calendar + formatter.timeZone = calendar.timeZone + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: trimmed) + } +} diff --git a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift index 7365c1cd..0cc96040 100644 --- a/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift @@ -37,6 +37,7 @@ public struct DeepSeekUsageSnapshot: Sendable { public let totalBalance: Double public let grantedBalance: Double public let toppedUpBalance: Double + public let usageSummary: DeepSeekUsageSummary? public let updatedAt: Date public init( @@ -45,6 +46,7 @@ public struct DeepSeekUsageSnapshot: Sendable { totalBalance: Double, grantedBalance: Double, toppedUpBalance: Double, + usageSummary: DeepSeekUsageSummary? = nil, updatedAt: Date) { self.isAvailable = isAvailable @@ -52,6 +54,7 @@ public struct DeepSeekUsageSnapshot: Sendable { self.totalBalance = totalBalance self.grantedBalance = grantedBalance self.toppedUpBalance = toppedUpBalance + self.usageSummary = usageSummary self.updatedAt = updatedAt } @@ -90,6 +93,7 @@ public struct DeepSeekUsageSnapshot: Sendable { secondary: nil, tertiary: nil, providerCost: nil, + deepseekUsage: self.usageSummary, updatedAt: self.updatedAt, identity: identity) } @@ -122,13 +126,104 @@ public enum DeepSeekUsageError: LocalizedError, Sendable { public struct DeepSeekUsageFetcher: Sendable { private static let log = CodexBarLog.logger(LogCategories.deepSeekUsage) private static let balanceURL = URL(string: "https://api.deepseek.com/user/balance")! + private static let usageAmountURL = URL(string: "https://platform.deepseek.com/api/v0/usage/amount")! + private static let usageCostURL = URL(string: "https://platform.deepseek.com/api/v0/usage/cost")! private static let timeoutSeconds: TimeInterval = 15 + private static let optionalSummaryJoinGrace: Duration = .seconds(2) + private static var apiCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = Locale(identifier: "en_US_POSIX") + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + return calendar + } - public static func fetchUsage(apiKey: String) async throws -> DeepSeekUsageSnapshot { + public static func fetchUsage( + apiKey: String, + includeOptionalUsage: Bool = true) async throws -> DeepSeekUsageSnapshot + { + try await self.fetchUsage( + apiKey: apiKey, + includeOptionalUsage: includeOptionalUsage, + optionalSummaryJoinGrace: self.optionalSummaryJoinGrace, + fetchBalanceData: { key in + try await self.fetchBalanceData(apiKey: key) + }, + fetchSummary: { key in + try await self.fetchUsageSummary(apiKey: key) + }) + } + + static func _fetchUsageForTesting( + apiKey: String, + includeOptionalUsage: Bool, + optionalSummaryJoinGrace: Duration = .zero, + fetchBalanceData: @escaping @Sendable (String) async throws -> Data, + fetchSummary: @escaping @Sendable (String) async throws -> DeepSeekUsageSummary) + async throws -> DeepSeekUsageSnapshot + { + try await self.fetchUsage( + apiKey: apiKey, + includeOptionalUsage: includeOptionalUsage, + optionalSummaryJoinGrace: optionalSummaryJoinGrace, + fetchBalanceData: fetchBalanceData, + fetchSummary: fetchSummary) + } + + private static func fetchUsage( + apiKey: String, + includeOptionalUsage: Bool, + optionalSummaryJoinGrace: Duration, + fetchBalanceData: @escaping @Sendable (String) async throws -> Data, + fetchSummary: @escaping @Sendable (String) async throws -> DeepSeekUsageSummary) + async throws -> DeepSeekUsageSnapshot + { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw DeepSeekUsageError.missingCredentials } + let summaryTask: Task? = if includeOptionalUsage { + Task { + try await fetchSummary(apiKey) + } + } else { + nil + } + + let balanceData: Data + do { + balanceData = try await fetchBalanceData(apiKey) + } catch { + summaryTask?.cancel() + throw error + } + var snapshot: DeepSeekUsageSnapshot + do { + snapshot = try Self.parseSnapshot(data: balanceData) + } catch { + summaryTask?.cancel() + throw error + } + + if let summaryTask { + let summary = try await self.completedOptionalUsageSummary( + from: summaryTask, + joinGrace: optionalSummaryJoinGrace) + if let summary { + snapshot = DeepSeekUsageSnapshot( + isAvailable: snapshot.isAvailable, + currency: snapshot.currency, + totalBalance: snapshot.totalBalance, + grantedBalance: snapshot.grantedBalance, + toppedUpBalance: snapshot.toppedUpBalance, + usageSummary: summary, + updatedAt: snapshot.updatedAt) + } + } + + return snapshot + } + + private static func fetchBalanceData(apiKey: String) async throws -> Data { var request = URLRequest(url: self.balanceURL) request.httpMethod = "GET" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") @@ -138,22 +233,156 @@ public struct DeepSeekUsageFetcher: Sendable { let response = try await ProviderHTTPClient.shared.response(for: request) let data = response.data guard response.statusCode == 200 else { - let body = String(data: data, encoding: .utf8) ?? "" - Self.log.error("DeepSeek API returned \(response.statusCode): \(body)") + Self.log.error("DeepSeek balance endpoint returned HTTP \(response.statusCode)") throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") } - if let jsonString = String(data: data, encoding: .utf8) { - Self.log.debug("DeepSeek API response: \(jsonString)") - } + return data + } + + private static func completedOptionalUsageSummary( + from task: Task, + joinGrace: Duration) async throws -> DeepSeekUsageSummary? + { + try await withTaskCancellationHandler { + do { + return try await withThrowingTaskGroup(of: DeepSeekUsageSummary?.self) { group in + group.addTask { + try await task.value + } + group.addTask { + if joinGrace > .zero { + try await Task.sleep(for: joinGrace) + } + return nil + } - return try Self.parseSnapshot(data: data) + let result = try await group.next().flatMap(\.self) + if result == nil { + task.cancel() + } + group.cancelAll() + return result + } + } catch { + task.cancel() + if Task.isCancelled { + throw error + } + return nil + } + } onCancel: { + task.cancel() + } } static func _parseSnapshotForTesting(_ data: Data) throws -> DeepSeekUsageSnapshot { try self.parseSnapshot(data: data) } + public static func fetchUsageSummary( + apiKey: String, + now: Date = Date(), + calendar: Calendar? = nil) async throws -> DeepSeekUsageSummary + { + let calendar = calendar ?? self.apiCalendar + let period = try self.usagePeriod(now: now, calendar: calendar) + + let amountData = try await self.fetchAmount(apiKey: apiKey, month: period.month, year: period.year) + let costData = try await self.fetchCost(apiKey: apiKey, month: period.month, year: period.year) + + return try DeepSeekUsageCostParser.parse( + amountData: amountData, + costData: costData, + now: now, + calendar: calendar) + } + + static func _apiUsagePeriodForTesting(now: Date, calendar: Calendar? = nil) throws -> (month: Int, year: Int) { + try self.usagePeriod(now: now, calendar: calendar ?? self.apiCalendar) + } + + private static func usagePeriod(now: Date, calendar: Calendar) throws -> (month: Int, year: Int) { + let monthComponents = calendar.dateComponents([.month, .year], from: now) + guard let month = monthComponents.month, let year = monthComponents.year else { + throw DeepSeekUsageError.parseFailed("Could not determine current month/year") + } + return (month: month, year: year) + } + + private static func fetchAmount(apiKey: String, month: Int, year: Int) async throws -> Data { + guard var components = URLComponents(url: self.usageAmountURL, resolvingAgainstBaseURL: false) else { + throw DeepSeekUsageError.networkError("Invalid URL") + } + components.queryItems = [ + URLQueryItem(name: "month", value: String(month)), + URLQueryItem(name: "year", value: String(year)), + ] + guard let url = components.url else { + throw DeepSeekUsageError.networkError("Could not construct URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + throw DeepSeekUsageError.missingCredentials + } + throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") + } + + return data + } + + private static func fetchCost(apiKey: String, month: Int, year: Int) async throws -> Data { + guard var components = URLComponents(url: self.usageCostURL, resolvingAgainstBaseURL: false) else { + throw DeepSeekUsageError.networkError("Invalid URL") + } + components.queryItems = [ + URLQueryItem(name: "month", value: String(month)), + URLQueryItem(name: "year", value: String(year)), + ] + guard let url = components.url else { + throw DeepSeekUsageError.networkError("Could not construct URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.timeoutInterval = Self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + if response.statusCode == 401 || response.statusCode == 403 { + throw DeepSeekUsageError.missingCredentials + } + throw DeepSeekUsageError.apiError("HTTP \(response.statusCode)") + } + + return data + } + + static func _parseUsageSummaryForTesting( + amountData: Data, + costData: Data, + now: Date = Date(), + calendar: Calendar = .current) throws -> DeepSeekUsageSummary + { + try DeepSeekUsageCostParser.parse( + amountData: amountData, + costData: costData, + now: now, + calendar: calendar) + } + private static func parseSnapshot(data: Data) throws -> DeepSeekUsageSnapshot { let decoded: DeepSeekBalanceResponse do { diff --git a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift index e3b1fb23..62897187 100644 --- a/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Factory/FactoryStatusProbe.swift @@ -816,15 +816,18 @@ public struct FactoryStatusProbe: Sendable { } private let browserDetection: BrowserDetection + private let transport: any ProviderHTTPTransport public init( baseURL: URL = URL(string: "https://app.factory.ai")!, timeout: TimeInterval = 15.0, - browserDetection: BrowserDetection) + browserDetection: BrowserDetection, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) { self.baseURL = baseURL self.timeout = timeout self.browserDetection = browserDetection + self.transport = transport } /// Fetch Factory usage using browser cookies with fallback to stored session. @@ -1266,7 +1269,7 @@ public struct FactoryStatusProbe: Sendable { let data: Data let response: URLResponse do { - (data, response) = try await ProviderHTTPClient.shared.data(for: request) + (data, response) = try await self.transport.data(for: request) } catch { return nil } @@ -1303,7 +1306,7 @@ public struct FactoryStatusProbe: Sendable { request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + let (data, response) = try await self.transport.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid response") @@ -1368,7 +1371,7 @@ public struct FactoryStatusProbe: Sendable { request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + let (data, response) = try await self.transport.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid response") @@ -1502,7 +1505,7 @@ public struct FactoryStatusProbe: Sendable { } request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) - let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + let (data, response) = try await self.transport.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid WorkOS response") } @@ -1572,7 +1575,7 @@ public struct FactoryStatusProbe: Sendable { } request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) - let (data, response) = try await ProviderHTTPClient.shared.data(for: request) + let (data, response) = try await self.transport.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FactoryStatusProbeError.networkError("Invalid WorkOS response") } @@ -1715,11 +1718,13 @@ public struct FactoryStatusProbe: Sendable { public init( baseURL: URL = URL(string: "https://app.factory.ai")!, timeout: TimeInterval = 15.0, - browserDetection: BrowserDetection) + browserDetection: BrowserDetection, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) { _ = baseURL _ = timeout _ = browserDetection + _ = transport } public func fetch( diff --git a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift index f3ed8697..a2d6e8b7 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokProviderDescriptor.swift @@ -10,7 +10,7 @@ public enum GrokProviderDescriptor { metadata: ProviderMetadata( id: .grok, displayName: "Grok", - sessionLabel: "Monthly", + sessionLabel: "Credits", weeklyLabel: "On-demand", opusLabel: nil, supportsOpus: false, @@ -53,6 +53,29 @@ public enum GrokProviderDescriptor { [] } } + + /// Returns a contextual label for Grok's primary usage bar ("Weekly" or "Monthly"). + /// Prefer the billing period duration when available; fall back to reset distance for + /// web billing payloads that expose only a reset timestamp. + public static func primaryLabel(window: RateWindow?, now: Date = .now) -> String? { + if let minutes = window?.windowMinutes { + return self.primaryLabel(duration: TimeInterval(minutes) * 60) + } + return self.primaryLabel(resetsAt: window?.resetsAt, now: now) + } + + public static func primaryLabel(resetsAt: Date?, now: Date = .now) -> String? { + guard let resetsAt else { return nil } + return self.primaryLabel(duration: resetsAt.timeIntervalSince(now)) + } + + private static func primaryLabel(duration seconds: TimeInterval) -> String? { + guard seconds > 3600 else { return nil } + let days = Int((seconds / 86400).rounded(.toNearestOrAwayFromZero)) + if (4...12).contains(days) { return "Weekly" } + if (20...45).contains(days) { return "Monthly" } + return nil + } } struct GrokCLIFetchStrategy: ProviderFetchStrategy { diff --git a/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift index 92238df8..a0feea79 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokRPCClient.swift @@ -353,6 +353,14 @@ extension GrokBillingResponse { return GrokBillingResponse.parseISO8601(raw) } + public var billingPeriodMinutes: Int? { + guard let start = self.billingPeriodStartDate, + let end = self.billingPeriodEndDate, + end > start + else { return nil } + return Int(end.timeIntervalSince(start) / 60) + } + private static func parseISO8601(_ raw: String) -> Date? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] diff --git a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift index cbbcc0c8..1666cee0 100644 --- a/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Grok/GrokStatusProbe.swift @@ -25,15 +25,15 @@ public struct GrokUsageSnapshot: Sendable { } public func toUsageSnapshot() -> UsageSnapshot { - // Primary window: monthly credit usage from the CLI RPC, falling back to - // the web billing RPC used by grok.com when the agent surface lacks billing. + // Primary window: credit usage (against included limit) from the CLI RPC, + // falling back to the web billing RPC used by grok.com when the agent surface lacks billing. var primary: RateWindow? if let billing, let percent = billing.monthlyUsedPercent { primary = RateWindow( usedPercent: percent, - windowMinutes: nil, + windowMinutes: billing.billingPeriodMinutes, resetsAt: billing.billingPeriodEndDate, resetDescription: nil) } else if let webBilling, diff --git a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAuthMode.swift b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAuthMode.swift index 89f4c9c8..ea0fd87a 100644 --- a/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAuthMode.swift +++ b/Sources/CodexBarCore/Providers/MiniMax/MiniMaxAuthMode.swift @@ -29,6 +29,14 @@ public enum MiniMaxAuthMode: Sendable { self != .apiToken } + public var description: String { + switch self { + case .apiToken: "apiToken" + case .cookie: "cookie" + case .none: "none" + } + } + private static func cleaned(_ raw: String?) -> String? { guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift index 7d27f365..1470a768 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralModels.swift @@ -207,4 +207,44 @@ public struct MistralUsageSnapshot: Codable, Sendable { updatedAt: self.updatedAt, identity: identity) } + + public func toCostUsageTokenSnapshot(historyDays: Int = 30) -> CostUsageTokenSnapshot { + let clampedHistoryDays = max(1, min(365, historyDays)) + let selected = self.daily + let entries = selected.map { bucket in + let modelBreakdowns = bucket.models.map { + CostUsageDailyReport.ModelBreakdown( + modelName: $0.name, + costUSD: max($0.cost, 0), + totalTokens: $0.totalTokens) + } + let modelsUsed = bucket.models.map(\.name) + return CostUsageDailyReport.Entry( + date: bucket.day, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheReadTokens: bucket.cachedTokens, + cacheCreationTokens: nil, + totalTokens: bucket.totalTokens, + costUSD: max(bucket.cost, 0), + modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, + modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) + } + let latest = selected.last + let totalCost = max(self.totalCost, 0) + let totalTokens = selected.isEmpty + ? self.totalInputTokens + self.totalCachedTokens + self.totalOutputTokens + : selected.reduce(0) { $0 + $1.totalTokens } + let tokens = totalTokens > 0 ? totalTokens : nil + return CostUsageTokenSnapshot( + sessionTokens: latest?.totalTokens, + sessionCostUSD: latest.map { max($0.cost, 0) }, + last30DaysTokens: tokens, + last30DaysCostUSD: totalCost, + currencyCode: self.currency, + historyDays: selected.isEmpty ? clampedHistoryDays : max(1, min(365, selected.count)), + historyLabel: "This month", + daily: entries, + updatedAt: self.updatedAt) + } } diff --git a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift index 914a2c5e..b1af5298 100644 --- a/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Mistral/MistralProviderDescriptor.swift @@ -30,8 +30,8 @@ public enum MistralProviderDescriptor { iconResourceName: "ProviderIcon-mistral", color: ProviderColor(red: 255 / 255, green: 80 / 255, blue: 15 / 255)), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, - noDataMessage: { "Mistral cost summary is not yet supported." }), + supportsTokenCost: true, + noDataMessage: { "Mistral cost history needs a billing web session." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MistralWebFetchStrategy()] })), diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift index 6b58dd80..d3aaea97 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageFetcher.swift @@ -620,6 +620,7 @@ public struct OllamaUsageFetcher: Sendable { } static func shouldAttachCookie(to url: URL?) -> Bool { + guard url?.scheme?.lowercased() == "https" else { return false } guard let host = url?.host?.lowercased() else { return false } if host == "ollama.com" || host == "www.ollama.com" { return true } return host.hasSuffix(".ollama.com") diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift index 0a15badc..53d3cf51 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageParser.swift @@ -2,6 +2,7 @@ import Foundation enum OllamaUsageParser { private static let primaryUsageLabels = ["Session usage", "Hourly usage"] + private static let usageLabels = primaryUsageLabels + ["Weekly usage"] enum ParseFailure: Equatable { case notLoggedIn @@ -44,12 +45,14 @@ enum OllamaUsageParser { weeklyUsedPercent: weekly?.usedPercent, sessionResetsAt: session?.resetsAt, weeklyResetsAt: weekly?.resetsAt, + sessionWindowMinutes: session?.windowMinutes, updatedAt: now)) } private struct UsageBlock { let usedPercent: Double let resetsAt: Date? + let windowMinutes: Int? } private static func parsePlanName(_ html: String) -> String? { @@ -72,11 +75,15 @@ enum OllamaUsageParser { private static func parseUsageBlock(label: String, html: String) -> UsageBlock? { guard let labelRange = html.range(of: label) else { return nil } let tail = String(html[labelRange.upperBound...]) - let window = String(tail.prefix(800)) + let window = self.usageBlockWindow(after: label, in: tail) guard let usedPercent = self.parsePercent(in: window) else { return nil } let resetsAt = self.parseISODate(in: window) - return UsageBlock(usedPercent: usedPercent, resetsAt: resetsAt) + let windowMinutes = label == "Session usage" ? 5 * 60 : nil + return UsageBlock( + usedPercent: usedPercent, + resetsAt: resetsAt, + windowMinutes: windowMinutes) } private static func parseUsageBlock(labels: [String], html: String) -> UsageBlock? { @@ -88,6 +95,16 @@ enum OllamaUsageParser { return nil } + private static func usageBlockWindow(after label: String, in tail: String) -> String { + let maxLength = 4000 + let boundary = self.usageLabels + .filter { $0 != label } + .compactMap { tail.range(of: $0)?.lowerBound } + .min() + let bounded = boundary.map { String(tail[..<$0]) } ?? String(tail.prefix(maxLength)) + return String(bounded.prefix(maxLength)) + } + private static func parsePercent(in text: String) -> Double? { let usedPattern = #"([0-9]+(?:\.[0-9]+)?)\s*%\s*used"# if let raw = self.firstCapture(in: text, pattern: usedPattern, options: [.caseInsensitive]) { diff --git a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift index 002f78d8..d3c7e327 100644 --- a/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Ollama/OllamaUsageSnapshot.swift @@ -7,6 +7,7 @@ public struct OllamaUsageSnapshot: Sendable { public let weeklyUsedPercent: Double? public let sessionResetsAt: Date? public let weeklyResetsAt: Date? + public let sessionWindowMinutes: Int? public let updatedAt: Date public init( @@ -16,6 +17,7 @@ public struct OllamaUsageSnapshot: Sendable { weeklyUsedPercent: Double?, sessionResetsAt: Date?, weeklyResetsAt: Date?, + sessionWindowMinutes: Int? = nil, updatedAt: Date) { self.planName = planName @@ -24,16 +26,17 @@ public struct OllamaUsageSnapshot: Sendable { self.weeklyUsedPercent = weeklyUsedPercent self.sessionResetsAt = sessionResetsAt self.weeklyResetsAt = weeklyResetsAt + self.sessionWindowMinutes = sessionWindowMinutes self.updatedAt = updatedAt } } extension OllamaUsageSnapshot { public func toUsageSnapshot() -> UsageSnapshot { - let sessionWindow = self.makeWindow( + let sessionWindow = self.makeSessionWindow( usedPercent: self.sessionUsedPercent, resetsAt: self.sessionResetsAt) - let weeklyWindow = self.makeWindow( + let weeklyWindow = self.makeWeeklyWindow( usedPercent: self.weeklyUsedPercent, resetsAt: self.weeklyResetsAt) @@ -54,12 +57,22 @@ extension OllamaUsageSnapshot { identity: identity) } - private func makeWindow(usedPercent: Double?, resetsAt: Date?) -> RateWindow? { + private func makeSessionWindow(usedPercent: Double?, resetsAt: Date?) -> RateWindow? { guard let usedPercent else { return nil } let clamped = min(100, max(0, usedPercent)) return RateWindow( usedPercent: clamped, - windowMinutes: nil, + windowMinutes: self.sessionWindowMinutes, + resetsAt: resetsAt, + resetDescription: nil) + } + + private func makeWeeklyWindow(usedPercent: Double?, resetsAt: Date?) -> RateWindow? { + guard let usedPercent else { return nil } + let clamped = min(100, max(0, usedPercent)) + return RateWindow( + usedPercent: clamped, + windowMinutes: 7 * 24 * 60, resetsAt: resetsAt, resetDescription: nil) } diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift index e9c6c8b8..009b369f 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIProviderDescriptor.swift @@ -28,7 +28,7 @@ public enum OpenAIAPIProviderDescriptor { iconResourceName: "ProviderIcon-codex", color: ProviderColor(red: 0.06, green: 0.51, blue: 0.43)), tokenCost: ProviderTokenCostConfig( - supportsTokenCost: false, + supportsTokenCost: true, noDataMessage: { "OpenAI usage needs an Admin API key for organization usage." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .api], @@ -43,13 +43,12 @@ public enum OpenAIAPIProviderDescriptor { struct OpenAIAPIBalanceFetchStrategy: ProviderFetchStrategy { let id: String = "openai.api.balance" let kind: ProviderFetchKind = .apiToken - let usageFetcher: @Sendable (String, Int) async throws -> OpenAIAPIUsageSnapshot + let usageFetcher: @Sendable (OpenAIAPIUsageCredential, Int) async throws -> OpenAIAPIUsageSnapshot let balanceFetcher: @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot init( - usageFetcher: @escaping @Sendable (String, Int) async throws -> OpenAIAPIUsageSnapshot = { apiKey, days in - try await OpenAIAPIUsageFetcher.fetchUsage(apiKey: apiKey, historyDays: days) - }, + usageFetcher: @escaping @Sendable (OpenAIAPIUsageCredential, Int) async throws -> OpenAIAPIUsageSnapshot = + OpenAIAPIBalanceFetchStrategy.fetchUsage(credential:days:), balanceFetcher: @escaping @Sendable (String) async throws -> OpenAIAPICreditBalanceSnapshot = { apiKey in try await OpenAIAPICreditBalanceFetcher.fetchBalance(apiKey: apiKey) }) @@ -59,24 +58,27 @@ struct OpenAIAPIBalanceFetchStrategy: ProviderFetchStrategy { } func isAvailable(_ context: ProviderFetchContext) async -> Bool { - Self.resolveToken(environment: context.env) != nil + OpenAIAPIUsageCredential(environment: context.env) != nil } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - guard let apiKey = Self.resolveToken(environment: context.env) else { + guard let credential = OpenAIAPIUsageCredential(environment: context.env) else { throw OpenAIAPISettingsError.missingToken } do { - let usage = try await self.usageFetcher(apiKey, context.costUsageHistoryDays) + let usage = try await self.usageFetcher(credential, context.costUsageHistoryDays) return self.makeResult( usage: usage.toUsageSnapshot(), - sourceLabel: "admin-api") + sourceLabel: credential.sourceLabel) } catch { let usageError = error - // Preserve the older balance-only path for project/user keys and admin API outages. + if !credential.allowsLegacyBalanceFallback { + throw usageError + } + // Preserve the older balance-only path for unscoped keys and Admin API outages. do { - let balance = try await self.balanceFetcher(apiKey) + let balance = try await self.balanceFetcher(credential.apiKey) return self.makeResult( usage: balance.toUsageSnapshot(), sourceLabel: "billing-api") @@ -93,7 +95,40 @@ struct OpenAIAPIBalanceFetchStrategy: ProviderFetchStrategy { false } - private static func resolveToken(environment: [String: String]) -> String? { - ProviderTokenResolver.openAIAPIToken(environment: environment) + private static func fetchUsage( + credential: OpenAIAPIUsageCredential, + days: Int) async throws -> OpenAIAPIUsageSnapshot + { + try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: credential.apiKey, + projectID: credential.projectID, + historyDays: days) + } +} + +struct OpenAIAPIUsageCredential: Equatable { + let apiKey: String + let projectID: String? + let usesAdminKey: Bool + + init?(environment: [String: String]) { + if let adminKey = OpenAIAPISettingsReader.adminAPIKey(environment: environment) { + self.apiKey = adminKey + self.usesAdminKey = true + } else if let apiKey = OpenAIAPISettingsReader.apiKey(environment: environment) { + self.apiKey = apiKey + self.usesAdminKey = false + } else { + return nil + } + self.projectID = OpenAIAPISettingsReader.projectID(environment: environment) + } + + var sourceLabel: String { + self.projectID == nil ? "admin-api" : "admin-api:project" + } + + var allowsLegacyBalanceFallback: Bool { + self.projectID == nil || !self.usesAdminKey } } diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift index 61ab0a22..a641872d 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPISettingsReader.swift @@ -3,6 +3,7 @@ import Foundation public enum OpenAIAPISettingsReader { public static let adminAPIKeyEnvironmentKey = "OPENAI_ADMIN_KEY" public static let apiKeyEnvironmentKey = "OPENAI_API_KEY" + public static let projectIDEnvironmentKey = "OPENAI_PROJECT_ID" public static let apiKeyEnvironmentKeys = [ Self.adminAPIKeyEnvironmentKey, Self.apiKeyEnvironmentKey, @@ -15,6 +16,14 @@ public enum OpenAIAPISettingsReader { return nil } + public static func adminAPIKey(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.adminAPIKeyEnvironmentKey]) + } + + public static func projectID(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.cleaned(environment[self.projectIDEnvironmentKey]) + } + static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift index c9e05d65..13570282 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageFetcher.swift @@ -40,39 +40,63 @@ public enum OpenAIAPIUsageFetcher { private static let maxDailyBucketLimit = 31 private static let timeoutSeconds: TimeInterval = 20 + private struct EndpointRequestContext { + let apiKey: String + let projectID: String? + let transport: any ProviderHTTPTransport + let retryPolicy: ProviderHTTPRetryPolicy + } + + private struct UsageEndpoint { + let name: String + let baseURL: URL + let queryItems: [URLQueryItem] + let decodeBuckets: (Data) throws -> [Bucket] + } + + private typealias SnapshotMetadata = (now: Date, calendar: Calendar, historyDays: Int, projectID: String?) + public static func fetchUsage( apiKey: String, + projectID: String? = nil, costsURL: URL = Self.organizationCostsURL, completionsURL: URL = Self.organizationCompletionsUsageURL, session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, now: Date = Date(), - historyDays: Int = 30) async throws -> OpenAIAPIUsageSnapshot + historyDays: Int = 30, + retryPolicy: ProviderHTTPRetryPolicy = .transientIdempotent) async throws -> OpenAIAPIUsageSnapshot { let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw OpenAIAPIUsageError.missingCredentials } + let normalizedProjectID = OpenAIAPISettingsReader.cleaned(projectID) let calendar = Self.utcCalendar let clampedHistoryDays = max(1, min(365, historyDays)) let ranges = Self.dailyRanges(now: now, calendar: calendar, historyDays: clampedHistoryDays) - let costs = try await Self.fetchCosts( - apiKey: trimmed, - baseURL: costsURL, - ranges: ranges, - transport: transport) - let completions = try await Self.fetchCompletions( + let requestContext = EndpointRequestContext( apiKey: trimmed, - baseURL: completionsURL, - ranges: ranges, - transport: transport) + projectID: normalizedProjectID, + transport: transport, + retryPolicy: retryPolicy) + let costs = try await Self.fetchBuckets( + endpoint: Self.costsEndpoint(baseURL: costsURL), + context: requestContext, + ranges: ranges) + let completions = try await Self.fetchBuckets( + endpoint: Self.completionsEndpoint(baseURL: completionsURL), + context: requestContext, + ranges: ranges) return Self.makeSnapshot( costs: costs, completions: completions, - now: now, - calendar: calendar, - historyDays: clampedHistoryDays) + metadata: SnapshotMetadata( + now: now, + calendar: calendar, + historyDays: clampedHistoryDays, + projectID: normalizedProjectID)) } static func _parseSnapshotForTesting( @@ -80,63 +104,70 @@ public enum OpenAIAPIUsageFetcher { completions: Data, now: Date, calendar: Calendar = Self.utcCalendar, - historyDays: Int = 30) throws -> OpenAIAPIUsageSnapshot + historyDays: Int = 30, + projectID: String? = nil) throws -> OpenAIAPIUsageSnapshot { - let costs = try Self.decodeCosts(costs) - let completions = try Self.decodeCompletions(completions) - return Self.makeSnapshot( - costs: costs, - completions: completions, - now: now, - calendar: calendar, - historyDays: historyDays) + try self.makeSnapshot( + costs: self.decodeCosts(costs).data, + completions: self.decodeCompletions(completions).data, + metadata: SnapshotMetadata( + now: now, + calendar: calendar, + historyDays: historyDays, + projectID: OpenAIAPISettingsReader.cleaned(projectID))) } - private static func fetchCosts( - apiKey: String, - baseURL: URL, - ranges: [DateRange], - transport: any ProviderHTTPTransport) async throws -> CostsResponse + private static func costsEndpoint(baseURL: URL) -> UsageEndpoint { + UsageEndpoint( + name: "costs", + baseURL: baseURL, + queryItems: [URLQueryItem(name: "group_by", value: "line_item")], + decodeBuckets: { try self.decodeCosts($0).data }) + } + + private static func completionsEndpoint( + baseURL: URL) -> UsageEndpoint { - var buckets: [CostBucket] = [] - for range in ranges { - let url = Self.url( - baseURL: baseURL, - range: range, - queryItems: [ - URLQueryItem(name: "group_by", value: "line_item"), - ]) - let data = try await Self.fetchData(url: url, apiKey: apiKey, endpoint: "costs", transport: transport) - try buckets.append(contentsOf: Self.decodeCosts(data).data) - } - return CostsResponse(data: buckets) + UsageEndpoint( + name: "completions", + baseURL: baseURL, + queryItems: [URLQueryItem(name: "group_by", value: "model")], + decodeBuckets: { try self.decodeCompletions($0).data }) } - private static func fetchCompletions( - apiKey: String, - baseURL: URL, - ranges: [DateRange], - transport: any ProviderHTTPTransport) async throws -> CompletionsUsageResponse + private static func fetchBuckets( + endpoint: UsageEndpoint, + context: EndpointRequestContext, + ranges: [DateRange]) async throws -> [Bucket] { - var buckets: [CompletionsUsageBucket] = [] + var buckets: [Bucket] = [] for range in ranges { let url = Self.url( - baseURL: baseURL, + baseURL: endpoint.baseURL, range: range, - queryItems: [ - URLQueryItem(name: "group_by", value: "model"), - ]) - let data = try await Self.fetchData(url: url, apiKey: apiKey, endpoint: "completions", transport: transport) - try buckets.append(contentsOf: Self.decodeCompletions(data).data) + queryItems: endpoint.queryItems + Self.projectQueryItems(projectID: context.projectID)) + let data = try await Self.fetchData( + url: url, + apiKey: context.apiKey, + endpoint: endpoint.name, + transport: context.transport, + retryPolicy: context.retryPolicy) + try buckets.append(contentsOf: endpoint.decodeBuckets(data)) } - return CompletionsUsageResponse(data: buckets) + return buckets + } + + private static func projectQueryItems(projectID: String?) -> [URLQueryItem] { + guard let projectID else { return [] } + return [URLQueryItem(name: "project_ids", value: projectID)] } private static func fetchData( url: URL, apiKey: String, endpoint: String, - transport: any ProviderHTTPTransport) async throws -> Data + transport: any ProviderHTTPTransport, + retryPolicy: ProviderHTTPRetryPolicy) async throws -> Data { var request = URLRequest(url: url) request.httpMethod = "GET" @@ -146,7 +177,7 @@ public enum OpenAIAPIUsageFetcher { let response: ProviderHTTPResponse do { - response = try await transport.response(for: request) + response = try await transport.response(for: request, retryPolicy: retryPolicy) } catch { throw OpenAIAPIUsageError.networkError(error.localizedDescription) } @@ -174,15 +205,13 @@ public enum OpenAIAPIUsageFetcher { } private static func makeSnapshot( - costs: CostsResponse, - completions: CompletionsUsageResponse, - now: Date, - calendar: Calendar, - historyDays: Int) -> OpenAIAPIUsageSnapshot + costs: [OpenAICostBucket], + completions: [OpenAICompletionsUsageBucket], + metadata: SnapshotMetadata) -> OpenAIAPIUsageSnapshot { var accumulators: [Int: DailyAccumulator] = [:] - for bucket in costs.data { + for bucket in costs { var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator( startTime: bucket.startTime, endTime: bucket.endTime) @@ -195,7 +224,7 @@ public enum OpenAIAPIUsageFetcher { accumulators[bucket.startTime] = accumulator } - for bucket in completions.data { + for bucket in completions { var accumulator = accumulators[bucket.startTime] ?? DailyAccumulator( startTime: bucket.startTime, endTime: bucket.endTime) @@ -224,10 +253,14 @@ public enum OpenAIAPIUsageFetcher { } let daily = accumulators.values - .filter { $0.startDate <= now } + .filter { $0.startDate <= metadata.now } .sorted { $0.startTime < $1.startTime } - .map { $0.makeBucket(calendar: calendar) } - return OpenAIAPIUsageSnapshot(daily: daily, updatedAt: now, historyDays: historyDays) + .map { $0.makeBucket(calendar: metadata.calendar) } + return OpenAIAPIUsageSnapshot( + daily: daily, + updatedAt: metadata.now, + historyDays: metadata.historyDays, + projectID: metadata.projectID) } private static func displayName(_ raw: String?, fallback: String) -> String { @@ -361,108 +394,3 @@ private struct ModelAccumulator { totalTokens: self.totalTokens) } } - -private struct CostsResponse: Decodable { - let data: [CostBucket] -} - -private struct CostBucket: Decodable { - let startTime: Int - let endTime: Int - let results: [CostResult] - - private enum CodingKeys: String, CodingKey { - case startTime = "start_time" - case endTime = "end_time" - case results - } -} - -private struct CostResult: Decodable { - struct Amount: Decodable { - let value: Double? - let currency: String? - - private enum CodingKeys: String, CodingKey { - case value - case currency - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.value = try container.decodeFlexibleDoubleIfPresent(forKey: .value) - self.currency = try container.decodeIfPresent(String.self, forKey: .currency) - } - } - - let amount: Amount? - let lineItem: String? - - private enum CodingKeys: String, CodingKey { - case amount - case lineItem = "line_item" - } -} - -extension KeyedDecodingContainer { - fileprivate func decodeFlexibleDoubleIfPresent(forKey key: Key) throws -> Double? { - guard self.contains(key), try !self.decodeNil(forKey: key) else { - return nil - } - - if let value = try? self.decode(Double.self, forKey: key) { - return value - } - - if let rawValue = try? self.decode(String.self, forKey: key) { - let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - if let value = Double(trimmed) { - return value - } - } - - throw DecodingError.dataCorruptedError( - forKey: key, - in: self, - debugDescription: "Expected a number or numeric string for \(key.stringValue)") - } -} - -private struct CompletionsUsageResponse: Decodable { - let data: [CompletionsUsageBucket] -} - -private struct CompletionsUsageBucket: Decodable { - let startTime: Int - let endTime: Int - let results: [CompletionsUsageResult] - - private enum CodingKeys: String, CodingKey { - case startTime = "start_time" - case endTime = "end_time" - case results - } -} - -private struct CompletionsUsageResult: Decodable { - let inputTokens: Int? - let inputCachedTokens: Int? - let inputAudioTokens: Int? - let outputTokens: Int? - let outputAudioTokens: Int? - let numModelRequests: Int? - let model: String? - - private enum CodingKeys: String, CodingKey { - case inputTokens = "input_tokens" - case inputCachedTokens = "input_cached_tokens" - case inputAudioTokens = "input_audio_tokens" - case outputTokens = "output_tokens" - case outputAudioTokens = "output_audio_tokens" - case numModelRequests = "num_model_requests" - case model - } -} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift new file mode 100644 index 00000000..02ad0485 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageResponses.swift @@ -0,0 +1,106 @@ +import Foundation + +struct CostsResponse: Decodable { + let data: [OpenAICostBucket] +} + +struct OpenAICostBucket: Decodable { + let startTime: Int + let endTime: Int + let results: [OpenAICostResult] + + private enum CodingKeys: String, CodingKey { + case startTime = "start_time" + case endTime = "end_time" + case results + } +} + +struct OpenAICostResult: Decodable { + struct Amount: Decodable { + let value: Double? + let currency: String? + + private enum CodingKeys: String, CodingKey { + case value + case currency + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.value = try container.decodeFlexibleDoubleIfPresent(forKey: .value) + self.currency = try container.decodeIfPresent(String.self, forKey: .currency) + } + } + + let amount: Amount? + let lineItem: String? + + private enum CodingKeys: String, CodingKey { + case amount + case lineItem = "line_item" + } +} + +struct CompletionsUsageResponse: Decodable { + let data: [OpenAICompletionsUsageBucket] +} + +struct OpenAICompletionsUsageBucket: Decodable { + let startTime: Int + let endTime: Int + let results: [OpenAICompletionsUsageResult] + + private enum CodingKeys: String, CodingKey { + case startTime = "start_time" + case endTime = "end_time" + case results + } +} + +struct OpenAICompletionsUsageResult: Decodable { + let inputTokens: Int? + let inputCachedTokens: Int? + let inputAudioTokens: Int? + let outputTokens: Int? + let outputAudioTokens: Int? + let numModelRequests: Int? + let model: String? + + private enum CodingKeys: String, CodingKey { + case inputTokens = "input_tokens" + case inputCachedTokens = "input_cached_tokens" + case inputAudioTokens = "input_audio_tokens" + case outputTokens = "output_tokens" + case outputAudioTokens = "output_audio_tokens" + case numModelRequests = "num_model_requests" + case model + } +} + +extension KeyedDecodingContainer { + fileprivate func decodeFlexibleDoubleIfPresent(forKey key: Key) throws -> Double? { + guard self.contains(key), try !self.decodeNil(forKey: key) else { + return nil + } + + if let value = try? self.decode(Double.self, forKey: key) { + return value + } + + if let rawValue = try? self.decode(String.self, forKey: key) { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + if let value = Double(trimmed) { + return value + } + } + + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Expected a number or numeric string for \(key.stringValue)") + } +} diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift index 09521456..9f741ea9 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -116,11 +116,13 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { public let daily: [DailyBucket] public let updatedAt: Date public let historyDays: Int + public let projectID: String? - public init(daily: [DailyBucket], updatedAt: Date, historyDays: Int = 30) { + public init(daily: [DailyBucket], updatedAt: Date, historyDays: Int = 30, projectID: String? = nil) { self.daily = daily.sorted { $0.startTime < $1.startTime } self.updatedAt = updatedAt self.historyDays = max(1, min(365, historyDays)) + self.projectID = OpenAIAPISettingsReader.cleaned(projectID) } public var last30Days: Summary { @@ -200,8 +202,54 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { identity: ProviderIdentitySnapshot( providerID: .openai, accountEmail: nil, - accountOrganization: nil, - loginMethod: "Admin API")) + accountOrganization: self.identityAccountOrganization, + loginMethod: self.identityLoginMethod)) + } + + private var identityLoginMethod: String { + guard let projectID else { return "Admin API" } + return "Admin API: \(projectID)" + } + + private var identityAccountOrganization: String? { + guard let projectID else { return nil } + return "Project: \(projectID)" + } + + public func toCostUsageTokenSnapshot() -> CostUsageTokenSnapshot { + let daily = self.daily.map { bucket in + let modelBreakdowns = bucket.models.map { + CostUsageDailyReport.ModelBreakdown( + modelName: $0.name, + costUSD: nil, + totalTokens: $0.totalTokens, + requestCount: $0.requests) + } + let modelsUsed = bucket.models.map(\.name) + return CostUsageDailyReport.Entry( + date: bucket.day, + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheReadTokens: bucket.cachedInputTokens, + cacheCreationTokens: nil, + totalTokens: bucket.totalTokens, + requestCount: bucket.requests, + costUSD: bucket.costUSD, + modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, + modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) + } + let latest = self.latestDay + let total = self.last30Days + return CostUsageTokenSnapshot( + sessionTokens: latest.totalTokens, + sessionCostUSD: latest.costUSD, + sessionRequests: latest.requests, + last30DaysTokens: total.totalTokens, + last30DaysCostUSD: total.costUSD, + last30DaysRequests: total.requests, + historyDays: self.historyDays, + daily: daily, + updatedAt: self.updatedAt) } private struct ModelAccumulator { diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift new file mode 100644 index 00000000..13098a44 --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoLocalUsageReader.swift @@ -0,0 +1,320 @@ +import Foundation + +#if os(macOS) +import SQLite3 + +public enum OpenCodeGoLocalUsageError: LocalizedError, Sendable, Equatable { + case notDetected + case historyUnavailable(String) + case sqliteFailed(String) + + public var errorDescription: String? { + switch self { + case .notDetected: + "OpenCode Go not detected. Log in with OpenCode Go or use it locally first." + case let .historyUnavailable(message): + "OpenCode Go local usage history is unavailable: \(message)" + case let .sqliteFailed(message): + "SQLite error reading OpenCode Go usage: \(message)" + } + } +} + +public struct OpenCodeGoLocalUsageReader: Sendable { + private static let fiveHours: TimeInterval = 5 * 60 * 60 + private static let week: TimeInterval = 7 * 24 * 60 * 60 + private static let limits = (session: 12.0, weekly: 30.0, monthly: 60.0) + + private let authURL: URL + private let databaseURL: URL + + public init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) { + let openCodeDirectory = homeDirectory + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("opencode", isDirectory: true) + self.authURL = openCodeDirectory.appendingPathComponent("auth.json", isDirectory: false) + self.databaseURL = openCodeDirectory.appendingPathComponent("opencode.db", isDirectory: false) + } + + public init(authURL: URL, databaseURL: URL) { + self.authURL = authURL + self.databaseURL = databaseURL + } + + public func fetch(now: Date = Date()) throws -> OpenCodeGoUsageSnapshot { + let hasAuth = Self.hasAuthKey(at: self.authURL) + guard FileManager.default.fileExists(atPath: self.databaseURL.path) else { + if hasAuth { + throw OpenCodeGoLocalUsageError.historyUnavailable("database not found") + } + throw OpenCodeGoLocalUsageError.notDetected + } + + let rows = try self.readRows() + guard hasAuth || !rows.isEmpty else { + throw OpenCodeGoLocalUsageError.notDetected + } + guard !rows.isEmpty else { + throw OpenCodeGoLocalUsageError.historyUnavailable("no local usage rows") + } + return Self.snapshot(rows: rows, now: now) + } + + private func readRows() throws -> [UsageRow] { + var db: OpaquePointer? + guard sqlite3_open_v2(self.databaseURL.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + sqlite3_close(db) + throw OpenCodeGoLocalUsageError.sqliteFailed(message) + } + defer { sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let sql = self.hasTable(named: "part", db: db) ? Self.messageAndPartUsageSQL : Self.messageUsageSQL + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw OpenCodeGoLocalUsageError.sqliteFailed(message) + } + defer { sqlite3_finalize(stmt) } + + var rows: [UsageRow] = [] + while true { + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { break } + guard step == SQLITE_ROW else { + let message = db.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "unknown error" + throw OpenCodeGoLocalUsageError.sqliteFailed(message) + } + + let createdMs = sqlite3_column_int64(stmt, 0) + let cost = sqlite3_column_double(stmt, 1) + guard createdMs > 0, cost >= 0, cost.isFinite else { continue } + rows.append(UsageRow(createdMs: createdMs, cost: cost)) + } + return rows + } + + private func hasTable(named name: String, db: OpaquePointer?) -> Bool { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", + -1, + &stmt, + nil) == SQLITE_OK + else { + return false + } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, name, -1, transient) + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static let messageUsageSQL = """ + SELECT + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, + CAST(json_extract(data, '$.cost') AS REAL) AS cost + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND json_type(data, '$.cost') IN ('integer', 'real') + """ + + private static let messageAndPartUsageSQL = """ + WITH message_costs AS ( + SELECT + id AS messageID, + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, + CAST(json_extract(data, '$.cost') AS REAL) AS cost + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND json_type(data, '$.cost') IN ('integer', 'real') + ) + SELECT createdMs, cost + FROM message_costs + UNION ALL + SELECT + CAST(COALESCE(json_extract(p.data, '$.time.created'), p.time_created, m.time_created) AS INTEGER) + AS createdMs, + CAST(json_extract(p.data, '$.cost') AS REAL) AS cost + FROM part p + JOIN message m ON m.id = p.message_id + WHERE json_valid(p.data) + AND json_valid(m.data) + AND json_extract(p.data, '$.type') = 'step-finish' + AND json_type(p.data, '$.cost') IN ('integer', 'real') + AND json_extract(m.data, '$.providerID') = 'opencode-go' + AND json_extract(m.data, '$.role') = 'assistant' + AND NOT EXISTS ( + SELECT 1 + FROM message_costs + WHERE message_costs.messageID = p.message_id + ) + """ + + private struct UsageRow { + let createdMs: Int64 + let cost: Double + } + + private static func hasAuthKey(at url: URL) -> Bool { + guard let data = try? Data(contentsOf: url), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let entry = object["opencode-go"] as? [String: Any], + let key = entry["key"] as? String + else { + return false + } + return !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private static func snapshot(rows: [UsageRow], now: Date) -> OpenCodeGoUsageSnapshot { + let nowMs = Int64(now.timeIntervalSince1970 * 1000) + let sessionStart = nowMs - Int64(Self.fiveHours * 1000) + let weekStart = self.startOfUTCWeek(now: now).timeIntervalSince1970 * 1000 + let weekStartMs = Int64(weekStart) + let weekEndMs = weekStartMs + Int64(Self.week * 1000) + let earliestMs = rows.map(\.createdMs).min() + let monthBounds = self.monthBounds(now: now, anchorMs: earliestMs) + + let sessionCost = self.sum(rows: rows, startMs: sessionStart, endMs: nowMs) + let weeklyCost = self.sum(rows: rows, startMs: weekStartMs, endMs: weekEndMs) + let monthlyCost = self.sum(rows: rows, startMs: monthBounds.startMs, endMs: monthBounds.endMs) + + return OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: self.percent(used: sessionCost, limit: self.limits.session), + weeklyUsagePercent: self.percent(used: weeklyCost, limit: self.limits.weekly), + monthlyUsagePercent: self.percent(used: monthlyCost, limit: self.limits.monthly), + rollingResetInSec: self.rollingReset(rows: rows, nowMs: nowMs), + weeklyResetInSec: max(0, Int((weekEndMs - nowMs) / 1000)), + monthlyResetInSec: max(0, Int((monthBounds.endMs - nowMs) / 1000)), + updatedAt: now) + } + + private static func sum(rows: [UsageRow], startMs: Int64, endMs: Int64) -> Double { + rows.reduce(0) { total, row in + guard row.createdMs >= startMs, row.createdMs < endMs else { return total } + return total + row.cost + } + } + + private static func percent(used: Double, limit: Double) -> Double { + guard used.isFinite, limit > 0 else { return 0 } + let value = max(0, min(100, used / limit * 100)) + return (value * 10).rounded() / 10 + } + + private static func rollingReset(rows: [UsageRow], nowMs: Int64) -> Int { + let sessionStart = nowMs - Int64(Self.fiveHours * 1000) + let oldest = rows + .filter { $0.createdMs >= sessionStart && $0.createdMs < nowMs } + .map(\.createdMs) + .min() ?? nowMs + return max(0, Int((oldest + Int64(Self.fiveHours * 1000) - nowMs) / 1000)) + } + + private static func startOfUTCWeek(now: Date) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current + calendar.firstWeekday = 2 + calendar.minimumDaysInFirstWeek = 4 + let components = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: now) + return calendar.date(from: components) ?? now + } + + private static func monthBounds(now: Date, anchorMs: Int64?) -> (startMs: Int64, endMs: Int64) { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? TimeZone.current + + guard let anchorMs else { + let start = calendar.date(from: calendar.dateComponents([.year, .month], from: now)) ?? now + let end = calendar.date(byAdding: .month, value: 1, to: start) ?? start + return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000)) + } + + let anchor = Date(timeIntervalSince1970: TimeInterval(anchorMs) / 1000) + let anchorComponents = calendar.dateComponents([.day, .hour, .minute, .second, .nanosecond], from: anchor) + let nowComponents = calendar.dateComponents([.year, .month], from: now) + + var startMonthComponents = nowComponents + var start = self.anchoredMonth(calendar: calendar, month: startMonthComponents, anchor: anchorComponents) + if start > now { + guard let previous = calendar.date(byAdding: .month, value: -1, to: start) else { + let end = self.anchoredMonth( + calendar: calendar, + month: self.monthComponents(after: startMonthComponents, calendar: calendar), + anchor: anchorComponents) + return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000)) + } + startMonthComponents = calendar.dateComponents([.year, .month], from: previous) + start = self.anchoredMonth(calendar: calendar, month: startMonthComponents, anchor: anchorComponents) + } + let end = self.anchoredMonth( + calendar: calendar, + month: self.monthComponents(after: startMonthComponents, calendar: calendar), + anchor: anchorComponents) + return (Int64(start.timeIntervalSince1970 * 1000), Int64(end.timeIntervalSince1970 * 1000)) + } + + private static func monthComponents(after month: DateComponents, calendar: Calendar) -> DateComponents { + let monthStart = calendar.date(from: month) ?? Date() + let nextMonth = calendar.date(byAdding: .month, value: 1, to: monthStart) ?? monthStart + return calendar.dateComponents([.year, .month], from: nextMonth) + } + + private static func anchoredMonth( + calendar: Calendar, + month: DateComponents, + anchor: DateComponents) -> Date + { + var components = DateComponents() + components.calendar = calendar + components.timeZone = calendar.timeZone + components.year = month.year + components.month = month.month + components.day = anchor.day + components.hour = anchor.hour + components.minute = anchor.minute + components.second = anchor.second + components.nanosecond = anchor.nanosecond + + if let date = calendar.date(from: components), + calendar.component(.month, from: date) == month.month + { + return date + } + + components.day = calendar.range(of: .day, in: .month, for: calendar.date(from: month) ?? Date())?.count + return calendar.date(from: components) ?? Date() + } +} + +#else + +public enum OpenCodeGoLocalUsageError: LocalizedError, Sendable, Equatable { + case notSupported + + public var errorDescription: String? { + "OpenCode Go local usage is only supported on macOS." + } +} + +public struct OpenCodeGoLocalUsageReader: Sendable { + public init(homeDirectory _: URL = FileManager.default.homeDirectoryForCurrentUser) {} + public init(authURL _: URL, databaseURL _: URL) {} + + public func fetch(now _: Date = Date()) throws -> OpenCodeGoUsageSnapshot { + throw OpenCodeGoLocalUsageError.notSupported + } +} + +#endif diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift index c5ed2a77..51b2ac41 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift @@ -33,11 +33,84 @@ public enum OpenCodeGoProviderDescriptor { noDataMessage: { "OpenCode Go cost summary is not supported." }), fetchPlan: ProviderFetchPlan( sourceModes: [.auto, .web], - pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [OpenCodeGoUsageFetchStrategy()] })), + pipeline: ProviderFetchPipeline(resolveStrategies: self.resolveStrategies)), cli: ProviderCLIConfig( name: "opencodego", versionDetector: nil)) } + + private static func resolveStrategies(context: ProviderFetchContext) async -> [any ProviderFetchStrategy] { + if context.sourceMode == .web { + return [OpenCodeGoUsageFetchStrategy()] + } + return [ + OpenCodeGoUsageFetchStrategy(), + OpenCodeGoLocalUsageFetchStrategy(), + ] + } +} + +struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { + let id: String = "opencodego.local" + let kind: ProviderFetchKind = .localProbe + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + let snapshot = try await self.snapshot(context: context) + return self.makeResult( + usage: snapshot.toUsageSnapshot(), + sourceLabel: "local") + } + + func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { + error is OpenCodeGoLocalUsageError + } + + private func snapshot(context: ProviderFetchContext) async throws -> OpenCodeGoUsageSnapshot { + let snapshot = try OpenCodeGoLocalUsageReader().fetch() + guard context.includeOptionalUsage, + context.settings?.opencodego?.cookieSource != .off + else { + return snapshot + } + + guard let cookieHeader = Self.cachedOrManualCookieHeader(context: context) else { + return snapshot + } + + let workspaceOverride = context.settings?.opencodego?.workspaceID + ?? context.env["CODEXBAR_OPENCODEGO_WORKSPACE_ID"] + let zenBalanceTask = Task { + do { + return try await OpenCodeGoUsageFetcher.fetchOptionalZenBalance( + cookieHeader: cookieHeader, + timeout: context.webTimeout, + workspaceIDOverride: workspaceOverride) + } catch is CancellationError { + throw CancellationError() + } catch { + return nil + } + } + let zenBalance = try await OpenCodeGoUsageFetcher.completedOptionalZenBalance(from: zenBalanceTask) + return snapshot.withZenBalanceUSD(zenBalance) + } + + private static func cachedOrManualCookieHeader(context: ProviderFetchContext) -> String? { + if let settings = context.settings?.opencodego, settings.cookieSource == .manual { + return OpenCodeWebCookieSupport.requestCookieHeader(from: settings.manualCookieHeader) + } + + #if os(macOS) + guard let cached = CookieHeaderCache.load(provider: .opencodego) else { return nil } + return OpenCodeWebCookieSupport.requestCookieHeader(from: cached.cookieHeader) + #else + return nil + #endif + } } struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { @@ -81,11 +154,19 @@ struct OpenCodeGoUsageFetchStrategy: ProviderFetchStrategy { } } - func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { - false + func shouldFallback(on error: Error, context: ProviderFetchContext) -> Bool { + guard context.sourceMode == .auto else { return false } + return switch error { + case OpenCodeGoSettingsError.missingCookie, + OpenCodeGoSettingsError.invalidCookie, + OpenCodeGoUsageError.invalidCredentials: + true + default: + false + } } - private static func resolveCookieHeader(context: ProviderFetchContext, allowCached: Bool) throws -> String { + static func resolveCookieHeader(context: ProviderFetchContext, allowCached: Bool) throws -> String { try OpenCodeWebCookieSupport.resolveCookieHeader( context: OpenCodeWebCookieSupport.Context( settings: context.settings?.opencodego, diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift index 39b290f8..70e18ec0 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageFetcher.swift @@ -151,6 +151,31 @@ public struct OpenCodeGoUsageFetcher: Sendable { return snapshot.withZenBalanceUSD(zenBalance) } + static func fetchOptionalZenBalance( + cookieHeader: String, + timeout: TimeInterval, + workspaceIDOverride: String? = nil, + session: URLSession? = nil) async throws -> Double? + { + let session = session ?? self.redirectGuardSession + guard let requestCookieHeader = OpenCodeWebCookieSupport.requestCookieHeader(from: cookieHeader) else { + throw OpenCodeGoUsageError.invalidCredentials + } + let workspaceID: String = if let override = self.normalizeWorkspaceID(workspaceIDOverride) { + override + } else { + try await self.fetchWorkspaceID( + cookieHeader: requestCookieHeader, + timeout: timeout, + session: session) + } + return try await self.fetchOptionalZenBalance( + workspaceID: workspaceID, + cookieHeader: requestCookieHeader, + timeout: min(timeout, self.optionalZenBalanceTimeout), + session: session) + } + static func allowsRedirect(from sourceURL: URL?, to destinationURL: URL?) -> Bool { guard let sourceHost = sourceURL?.host?.lowercased(), let destinationHost = destinationURL?.host?.lowercased(), @@ -168,7 +193,9 @@ public struct OpenCodeGoUsageFetcher: Sendable { } return url } +} +extension OpenCodeGoUsageFetcher { private static func fetchWorkspaceID( cookieHeader: String, timeout: TimeInterval, diff --git a/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift new file mode 100644 index 00000000..4ce2fad2 --- /dev/null +++ b/Sources/CodexBarCore/Providers/ProviderDiagnosticExport.swift @@ -0,0 +1,415 @@ +import Foundation + +public struct ProviderDiagnosticBatchExport: Codable, Sendable { + public let schemaVersion: String + public let timestamp: Date + public let diagnostics: [ProviderDiagnosticExport] + + public init( + schemaVersion: String = "1.0", + timestamp: Date, + diagnostics: [ProviderDiagnosticExport]) + { + self.schemaVersion = schemaVersion + self.timestamp = timestamp + self.diagnostics = diagnostics + } +} + +public struct ProviderDiagnosticExport: Codable, Sendable { + public let schemaVersion: String + public let timestamp: Date + public let provider: String + public let displayName: String + public let source: String + public let sourceMode: String + public let auth: ProviderDiagnosticAuthSummary + public let usage: ProviderDiagnosticUsageSummary? + public let fetchAttempts: [ProviderDiagnosticFetchAttempt] + public let error: ProviderDiagnosticError? + public let settings: ProviderDiagnosticSettingsSummary + public let details: ProviderDiagnosticDetails? + + public init( + schemaVersion: String = "1.0", + timestamp: Date, + provider: String, + displayName: String, + source: String, + sourceMode: String, + auth: ProviderDiagnosticAuthSummary, + usage: ProviderDiagnosticUsageSummary?, + fetchAttempts: [ProviderDiagnosticFetchAttempt], + error: ProviderDiagnosticError?, + settings: ProviderDiagnosticSettingsSummary, + details: ProviderDiagnosticDetails?) + { + self.schemaVersion = schemaVersion + self.timestamp = timestamp + self.provider = provider + self.displayName = displayName + self.source = source + self.sourceMode = sourceMode + self.auth = auth + self.usage = usage + self.fetchAttempts = fetchAttempts + self.error = error + self.settings = settings + self.details = details + } +} + +public struct ProviderDiagnosticAuthSummary: Codable, Sendable { + public let configured: Bool + public let modes: [String] + + public init(configured: Bool, modes: [String]) { + self.configured = configured + self.modes = modes + } + + public func resolved(with outcome: ProviderFetchOutcome) -> ProviderDiagnosticAuthSummary { + var resolvedModes = self.modes + if outcome.isSuccess { + for attempt in outcome.attempts where attempt.wasAvailable { + let mode = ProviderDiagnosticFetchAttempt.kindLabel(attempt.kind) + if !resolvedModes.contains(mode) { + resolvedModes.append(mode) + } + } + } + let configured = self.configured || outcome.isSuccess + return ProviderDiagnosticAuthSummary(configured: configured, modes: resolvedModes) + } +} + +public struct ProviderDiagnosticUsageSummary: Codable, Sendable { + public let updatedAt: Date + public let windows: [ProviderDiagnosticRateWindow] + public let extraWindowCount: Int + public let providerCostPresent: Bool + public let providerSpecificData: [String] + + public init(from snapshot: UsageSnapshot) { + var windows: [ProviderDiagnosticRateWindow] = [] + if let primary = snapshot.primary { + windows.append(ProviderDiagnosticRateWindow(label: "primary", window: primary)) + } + if let secondary = snapshot.secondary { + windows.append(ProviderDiagnosticRateWindow(label: "secondary", window: secondary)) + } + if let tertiary = snapshot.tertiary { + windows.append(ProviderDiagnosticRateWindow(label: "tertiary", window: tertiary)) + } + for extra in snapshot.extraRateWindows ?? [] { + windows.append(ProviderDiagnosticRateWindow(label: extra.title, window: extra.window)) + } + + var providerSpecificData: [String] = [] + if snapshot.kiroUsage != nil { providerSpecificData.append("kiroUsage") } + if snapshot.zaiUsage != nil { providerSpecificData.append("zaiUsage") } + if snapshot.minimaxUsage != nil { providerSpecificData.append("minimaxUsage") } + if snapshot.deepseekUsage != nil { providerSpecificData.append("deepseekUsage") } + if snapshot.openRouterUsage != nil { providerSpecificData.append("openRouterUsage") } + if snapshot.openAIAPIUsage != nil { providerSpecificData.append("openAIAPIUsage") } + if snapshot.claudeAdminAPIUsage != nil { providerSpecificData.append("claudeAdminAPIUsage") } + if snapshot.mistralUsage != nil { providerSpecificData.append("mistralUsage") } + if snapshot.deepgramUsage != nil { providerSpecificData.append("deepgramUsage") } + if snapshot.cursorRequests != nil { providerSpecificData.append("cursorRequests") } + + self.updatedAt = snapshot.updatedAt + self.windows = windows + self.extraWindowCount = snapshot.extraRateWindows?.count ?? 0 + self.providerCostPresent = snapshot.providerCost != nil + self.providerSpecificData = providerSpecificData.sorted() + } +} + +public struct ProviderDiagnosticRateWindow: Codable, Sendable { + public let label: String + public let usedPercent: Double + public let windowMinutes: Int? + public let resetsAt: Date? + public let hasResetDescription: Bool + public let nextRegenPercent: Double? + + public init(label: String, window: RateWindow) { + self.label = label + self.usedPercent = window.usedPercent + self.windowMinutes = window.windowMinutes + self.resetsAt = window.resetsAt + self.hasResetDescription = window.resetDescription?.isEmpty == false + self.nextRegenPercent = window.nextRegenPercent + } +} + +public struct ProviderDiagnosticFetchAttempt: Codable, Sendable { + public let kind: String + public let wasAvailable: Bool + public let errorCategory: String? + + public init( + kind: String, + wasAvailable: Bool, + errorCategory: String?) + { + self.kind = kind + self.wasAvailable = wasAvailable + self.errorCategory = errorCategory + } + + public init(from attempt: ProviderFetchAttempt) { + self.kind = Self.kindLabel(attempt.kind) + self.wasAvailable = attempt.wasAvailable + self.errorCategory = attempt.errorDescription.map(Self.errorCategoryLabel) + } + + public static func kindLabel(_ kind: ProviderFetchKind) -> String { + switch kind { + case .cli: "cli" + case .web: "web" + case .oauth: "oauth" + case .apiToken: "api" + case .localProbe: "local" + case .webDashboard: "web" + } + } + + public static func errorCategoryLabel(_ description: String?) -> String { + guard let desc = description?.lowercased() else { return "unknown" } + if desc.contains("network") || desc.contains("timeout") || desc.contains("connection") { + return "network" + } + if desc.contains("auth") || desc.contains("credential") || desc.contains("token") || desc.contains("cookie") || + desc.contains("api key") || desc.contains("key not configured") || desc.contains("missing key") + { + return "auth" + } + if desc.contains("source") || desc.contains("not supported") || desc.contains("unavailable") { + return "configuration" + } + if desc.contains("api") || desc.contains("http") || desc.contains("404") || desc.contains("403") { + return "api" + } + if desc.contains("parse") || desc.contains("format") || desc.contains("decode") { + return "parse" + } + return "unknown" + } +} + +public struct ProviderDiagnosticError: Codable, Sendable { + public let category: String + public let safeDescription: String + + public init(category: String, safeDescription: String) { + self.category = category + self.safeDescription = safeDescription + } + + public init(from error: Error, authConfigured: Bool) { + self.category = Self.errorCategory(error, authConfigured: authConfigured) + self.safeDescription = Self.safeDescription(category: self.category) + } + + private static func errorCategory(_ error: Error, authConfigured: Bool) -> String { + if case ProviderFetchError.noAvailableStrategy = error { + return authConfigured ? "configuration" : "auth" + } + if let minimaxError = error as? MiniMaxUsageError { + switch minimaxError { + case .networkError: return "network" + case .invalidCredentials: return "auth" + case .apiError: return "api" + case .parseFailed: return "parse" + } + } + if error is MiniMaxSettingsError || error is MiniMaxAPISettingsError { return "auth" } + return ProviderDiagnosticFetchAttempt.errorCategoryLabel(error.localizedDescription) + } + + private static func safeDescription(category: String) -> String { + switch category { + case "network": + "Network error - check your connection" + case "auth": + "Authentication or setup issue - check provider credentials" + case "api": + "API error - service returned an unexpected response" + case "parse": + "Parse error - unexpected response format" + case "configuration": + "Configuration issue - check provider source and settings" + default: + "An unexpected error occurred" + } + } +} + +public struct ProviderDiagnosticSettingsSummary: Codable, Sendable { + public let sourceMode: String + public let apiRegion: String? + + public init(sourceMode: ProviderSourceMode, apiRegion: String? = nil) { + self.sourceMode = sourceMode.rawValue + self.apiRegion = apiRegion + } +} + +public enum ProviderDiagnosticDetails: Codable, Sendable { + case minimax(MiniMaxDiagnosticDetails) + + private enum CodingKeys: String, CodingKey { + case type + case minimax + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "minimax": + self = try .minimax(container.decode(MiniMaxDiagnosticDetails.self, forKey: .minimax)) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown provider diagnostic detail type") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .minimax(details): + try container.encode("minimax", forKey: .type) + try container.encode(details, forKey: .minimax) + } + } +} + +public struct MiniMaxDiagnosticDetails: Codable, Sendable { + public let planName: String? + public let availablePrompts: Int? + public let currentPrompts: Int? + public let remainingPrompts: Int? + public let windowMinutes: Int? + public let usedPercent: Double? + public let resetsAt: Date? + public let services: [MiniMaxDiagnosticServiceUsage]? + public let billingSummaryPresent: Bool + + public init(from snapshot: MiniMaxUsageSnapshot) { + self.planName = snapshot.planName + self.availablePrompts = snapshot.availablePrompts + self.currentPrompts = snapshot.currentPrompts + self.remainingPrompts = snapshot.remainingPrompts + self.windowMinutes = snapshot.windowMinutes + self.usedPercent = snapshot.usedPercent + self.resetsAt = snapshot.resetsAt + self.services = snapshot.services?.map { MiniMaxDiagnosticServiceUsage(from: $0) } + self.billingSummaryPresent = snapshot.billingSummary != nil + } +} + +public struct MiniMaxDiagnosticServiceUsage: Codable, Sendable { + public let displayName: String + public let percent: Double + public let windowType: String + public let resetsAt: Date? + public let hasResetDescription: Bool + + public init(from service: MiniMaxServiceUsage) { + self.displayName = service.displayName + self.percent = service.percent + self.windowType = service.windowType + self.resetsAt = service.resetsAt + self.hasResetDescription = !service.resetDescription.isEmpty + } +} + +public enum ProviderDiagnosticExportBuilder { + public struct Input: Sendable { + public let provider: UsageProvider + public let descriptor: ProviderDescriptor + public let outcome: ProviderFetchOutcome + public let sourceMode: ProviderSourceMode + public let settings: ProviderSettingsSnapshot? + public let auth: ProviderDiagnosticAuthSummary + + public init( + provider: UsageProvider, + descriptor: ProviderDescriptor, + outcome: ProviderFetchOutcome, + sourceMode: ProviderSourceMode, + settings: ProviderSettingsSnapshot?, + auth: ProviderDiagnosticAuthSummary) + { + self.provider = provider + self.descriptor = descriptor + self.outcome = outcome + self.sourceMode = sourceMode + self.settings = settings + self.auth = auth + } + } + + public static func build(_ input: Input) -> ProviderDiagnosticExport { + let resolvedAuth = input.auth.resolved(with: input.outcome) + let usage = input.outcome.usageSnapshot.map { ProviderDiagnosticUsageSummary(from: $0) } + let error = input.outcome.failureError + .map { ProviderDiagnosticError(from: $0, authConfigured: resolvedAuth.configured) } + let settingsSummary = ProviderDiagnosticSettingsSummary( + sourceMode: input.sourceMode, + apiRegion: Self.safeAPIRegion(provider: input.provider, settings: input.settings)) + + return ProviderDiagnosticExport( + timestamp: Date(), + provider: input.provider.rawValue, + displayName: input.descriptor.metadata.displayName, + source: input.outcome.sourceLabel, + sourceMode: input.sourceMode.rawValue, + auth: resolvedAuth, + usage: usage, + fetchAttempts: input.outcome.attempts.map { ProviderDiagnosticFetchAttempt(from: $0) }, + error: error, + settings: settingsSummary, + details: Self.details(provider: input.provider, outcome: input.outcome)) + } + + private static func safeAPIRegion(provider: UsageProvider, settings: ProviderSettingsSnapshot?) -> String? { + guard provider == .minimax else { return nil } + return settings?.minimax?.apiRegion.rawValue ?? "global" + } + + private static func details(provider: UsageProvider, outcome: ProviderFetchOutcome) -> ProviderDiagnosticDetails? { + guard provider == .minimax, + let usage = outcome.usageSnapshot?.minimaxUsage + else { + return nil + } + return .minimax(MiniMaxDiagnosticDetails(from: usage)) + } +} + +extension ProviderFetchOutcome { + fileprivate var isSuccess: Bool { + guard case .success = self.result else { return false } + return true + } + + fileprivate var sourceLabel: String { + guard case let .success(result) = result else { return "failed" } + return result.sourceLabel + } + + fileprivate var usageSnapshot: UsageSnapshot? { + guard case let .success(result) = result else { return nil } + return result.usage + } + + fileprivate var failureError: Error? { + guard case let .failure(error) = result else { return nil } + return error + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift index 00516827..e99a2c69 100644 --- a/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift +++ b/Sources/CodexBarCore/Providers/ProviderFetchPlan.swift @@ -19,6 +19,7 @@ public enum ProviderSourceMode: String, CaseIterable, Sendable, Codable { public struct ProviderFetchContext: Sendable { public typealias TokenAccountTokenUpdater = @Sendable (UsageProvider, UUID, String) async -> Void + public typealias ProviderManualTokenUpdater = @Sendable (UsageProvider, String) async -> Void public let runtime: ProviderRuntime public let sourceMode: ProviderSourceMode @@ -34,6 +35,7 @@ public struct ProviderFetchContext: Sendable { public let browserDetection: BrowserDetection public let selectedTokenAccountID: UUID? public let tokenAccountTokenUpdater: TokenAccountTokenUpdater? + public let providerManualTokenUpdater: ProviderManualTokenUpdater? public let costUsageHistoryDays: Int public init( @@ -51,6 +53,7 @@ public struct ProviderFetchContext: Sendable { browserDetection: BrowserDetection, selectedTokenAccountID: UUID? = nil, tokenAccountTokenUpdater: TokenAccountTokenUpdater? = nil, + providerManualTokenUpdater: ProviderManualTokenUpdater? = nil, costUsageHistoryDays: Int = 30) { self.runtime = runtime @@ -67,6 +70,7 @@ public struct ProviderFetchContext: Sendable { self.browserDetection = browserDetection self.selectedTokenAccountID = selectedTokenAccountID self.tokenAccountTokenUpdater = tokenAccountTokenUpdater + self.providerManualTokenUpdater = providerManualTokenUpdater self.costUsageHistoryDays = max(1, min(365, costUsageHistoryDays)) } } diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift index a8178d21..5724db3b 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunProviderDescriptor.swift @@ -51,22 +51,14 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - let cookieSource = context.settings?.stepfun?.cookieSource ?? .auto - do { - let token = try await Self.resolveToken(context: context, allowCached: true) - let usage = try await StepFunUsageFetcher.fetchUsage(token: token) - return self.makeResult( - usage: usage.toUsageSnapshot(), - sourceLabel: "web") - } catch StepFunUsageError.apiError where cookieSource != .manual { - // Token may be stale — clear cache and retry with fresh login - CookieHeaderCache.clear(provider: .stepfun) - let token = try await Self.resolveToken(context: context, allowCached: false) - let usage = try await StepFunUsageFetcher.fetchUsage(token: token) + let resolved = try await Self.resolveToken(context: context, allowCached: true) + let usage = try await StepFunUsageFetcher.fetchUsage(token: resolved.token) return self.makeResult( usage: usage.toUsageSnapshot(), sourceLabel: "web") + } catch let error where Self.isAuthenticationFailure(error) { + return try await self.recoverFromAuthenticationFailure(context: context, originalError: error) } } @@ -76,9 +68,22 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { // MARK: - Token Resolution + private struct ResolvedToken { + let token: String + let source: TokenSource + } + + private enum TokenSource { + case manual + case cached + case settingsLogin + case environmentToken + case environmentLogin + } + private static func resolveToken( context: ProviderFetchContext, - allowCached: Bool) async throws -> String + allowCached: Bool) async throws -> ResolvedToken { let settings = context.settings?.stepfun @@ -88,12 +93,16 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { guard !manualToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw StepFunUsageError.missingToken } - return StepFunTokenNormalizer.normalize(manualToken) + return ResolvedToken( + token: StepFunTokenNormalizer.normalize(manualToken), + source: .manual) } // 2. Cached token from previous login if allowCached, let cached = CookieHeaderCache.load(provider: .stepfun) { - return StepFunTokenNormalizer.normalize(cached.cookieHeader) + return ResolvedToken( + token: StepFunTokenNormalizer.normalize(cached.cookieHeader), + source: .cached) } // 3. Username + password from Settings UI → perform full login flow @@ -103,12 +112,12 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { username: settings.username, password: settings.password) CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") - return token + return ResolvedToken(token: token, source: .settingsLogin) } // 4. Direct token from env var if let token = StepFunSettingsReader.token(environment: context.env) { - return token + return ResolvedToken(token: token, source: .environmentToken) } // 5. Username + password from env vars → perform full login flow @@ -117,11 +126,170 @@ struct StepFunWebFetchStrategy: ProviderFetchStrategy { { let token = try await StepFunUsageFetcher.login(username: username, password: password) CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") - return token + return ResolvedToken(token: token, source: .environmentLogin) } throw StepFunUsageError.missingCredentials } + + private func recoverFromAuthenticationFailure( + context: ProviderFetchContext, + originalError: Error) async throws -> ProviderFetchResult + { + let resolved = try await Self.resolveToken(context: context, allowCached: true) + let refreshed: String + do { + refreshed = try await StepFunUsageFetcher.refreshToken(token: resolved.token) + } catch { + if let fallback = try await Self.resolvedTokenWithoutStaleCache(context: context, source: resolved.source) { + do { + let usage = try await StepFunUsageFetcher.fetchUsage(token: fallback.token) + await Self.persistRecoveredToken(fallback.token, source: fallback.source, context: context) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } catch { + if !Self.isAuthenticationFailure(error) { + throw error + } + } + } + if let loginToken = try await Self.loginTokenIfAvailable(context: context, source: resolved.source) { + let usage = try await StepFunUsageFetcher.fetchUsage(token: loginToken) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } + throw Self.actionableAuthenticationError(for: resolved.source, originalError: originalError) + } + + await Self.persistRecoveredToken(refreshed, source: resolved.source, context: context) + + do { + let usage = try await StepFunUsageFetcher.fetchUsage(token: refreshed) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } catch let retryError where Self.isAuthenticationFailure(retryError) { + if let loginToken = try await Self.loginTokenIfAvailable(context: context, source: resolved.source) { + let usage = try await StepFunUsageFetcher.fetchUsage(token: loginToken) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "web") + } + throw Self.actionableAuthenticationError(for: resolved.source, originalError: originalError) + } + } + + private static func resolvedTokenWithoutStaleCache( + context: ProviderFetchContext, + source: TokenSource) async throws -> ResolvedToken? + { + guard case .cached = source else { return nil } + CookieHeaderCache.clear(provider: .stepfun) + do { + return try await self.resolveToken(context: context, allowCached: false) + } catch StepFunUsageError.missingCredentials { + return nil + } catch StepFunUsageError.missingToken { + return nil + } + } + + private static func loginTokenIfAvailable( + context: ProviderFetchContext, + source: TokenSource) async throws -> String? + { + if case .manual = source { + return nil + } + + let settings = context.settings?.stepfun + if settings?.cookieSource != .manual, + let settings, + !settings.username.isEmpty, + !settings.password.isEmpty + { + CookieHeaderCache.clear(provider: .stepfun) + let token = try await StepFunUsageFetcher.login( + username: settings.username, + password: settings.password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return token + } + + if let username = StepFunSettingsReader.username(environment: context.env), + let password = StepFunSettingsReader.password(environment: context.env) + { + CookieHeaderCache.clear(provider: .stepfun) + let token = try await StepFunUsageFetcher.login(username: username, password: password) + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "login") + return token + } + + return nil + } + + private static func persistRecoveredToken( + _ token: String, + source: TokenSource, + context: ProviderFetchContext) async + { + switch source { + case .cached, .settingsLogin, .environmentLogin: + CookieHeaderCache.store(provider: .stepfun, cookieHeader: token, sourceLabel: "refresh") + case .manual: + guard let accountID = context.selectedTokenAccountID, + let updater = context.tokenAccountTokenUpdater + else { + await context.providerManualTokenUpdater?(.stepfun, token) + return + } + await updater(.stepfun, accountID, token) + case .environmentToken: + guard let accountID = context.selectedTokenAccountID, + let updater = context.tokenAccountTokenUpdater + else { return } + await updater(.stepfun, accountID, token) + } + } + + private static func isAuthenticationFailure(_ error: Error) -> Bool { + guard case let StepFunUsageError.apiError(message) = error else { + return false + } + let lower = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return lower.contains("401") || + lower.contains("403") || + lower.contains("unauthorized") || + lower.contains("unauthenticated") || + lower.contains("invalid credentials") || + lower.contains("invalid token") || + lower.contains("token expired") || + lower.contains("expired token") + } + + private static func actionableAuthenticationError( + for source: TokenSource, + originalError: Error) -> StepFunUsageError + { + let suffix = switch source { + case .manual: + "Refresh the Oasis-Token, or switch StepFun to auto auth with username/password." + case .environmentToken: + "Refresh STEPFUN_TOKEN, or configure STEPFUN_USERNAME and STEPFUN_PASSWORD." + case .cached, .settingsLogin, .environmentLogin: + "Refresh the StepFun credentials and try again." + } + return .apiError("\(Self.authenticationFailureMessage(originalError)). \(suffix)") + } + + private static func authenticationFailureMessage(_ error: Error) -> String { + if case let StepFunUsageError.apiError(message) = error { + return message + } + return error.localizedDescription + } } // MARK: - Token Normalizer diff --git a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift index 8fdbdc8a..9c8bca18 100644 --- a/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/StepFun/StepFunUsageFetcher.swift @@ -108,6 +108,11 @@ struct StepFunLoginResponse: Decodable { let refreshToken: StepFunTokenPair? } +struct StepFunRefreshTokenResponse: Decodable { + let accessToken: StepFunTokenPair? + let refreshToken: StepFunTokenPair? +} + struct StepFunTokenPair: Decodable { let raw: String } @@ -184,6 +189,7 @@ public enum StepFunUsageError: LocalizedError, Sendable { case apiError(String) case parseFailed(String) case loginFailed(String) + case tokenRefreshFailed(String) case deviceRegistrationFailed(String) public var errorDescription: String? { @@ -200,6 +206,8 @@ public enum StepFunUsageError: LocalizedError, Sendable { "Failed to parse StepFun response: \(message)" case let .loginFailed(message): "StepFun login failed: \(message)" + case let .tokenRefreshFailed(message): + "StepFun token refresh failed: \(message)" case let .deviceRegistrationFailed(message): "StepFun device registration failed: \(message)" } @@ -219,6 +227,8 @@ public struct StepFunUsageFetcher: Sendable { URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/RegisterDevice")! private static let loginURL = URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/SignInByPassword")! + private static let refreshTokenURL = + URL(string: "https://platform.stepfun.com/passport/proto.api.passport.v1.PassportService/RefreshToken")! private static let timeoutSeconds: TimeInterval = 15 private static let webID = "c8a1002d2c457e758785a9979832217c7c0b884c" @@ -241,6 +251,11 @@ public struct StepFunUsageFetcher: Sendable { try await self.fullLogin(username: username, password: password) } + /// Refresh an existing Oasis-Token and return a fresh access + refresh token pair. + public static func refreshToken(token: String) async throws -> String { + try await self.refreshOasisToken(token: token) + } + /// Fetch usage data using an existing Oasis-Token (from env var or cached). public static func fetchUsage(token: String) async throws -> StepFunUsageSnapshot { guard !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { @@ -342,9 +357,7 @@ public struct StepFunUsageFetcher: Sendable { throw StepFunUsageError.deviceRegistrationFailed("No access token in RegisterDevice response") } - let refreshToken = decoded.refreshToken?.raw ?? "" - // Combine access + refresh tokens like the Python tool does - return "\(accessToken)...\(refreshToken)" + return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) } private static func signInByPassword( @@ -384,7 +397,53 @@ public struct StepFunUsageFetcher: Sendable { throw StepFunUsageError.loginFailed("No access token in login response") } - let refreshToken = decoded.refreshToken?.raw ?? "" + return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) + } + + private static func refreshOasisToken(token: String) async throws -> String { + let normalized = StepFunTokenNormalizer.normalize(token) + guard !normalized.isEmpty else { + throw StepFunUsageError.missingToken + } + + var request = URLRequest(url: self.refreshTokenURL) + request.httpMethod = "POST" + request.httpBody = Data("{}".utf8) + for (key, value) in self.baseHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue(normalized, forHTTPHeaderField: "Oasis-Token") + request.setValue( + "Oasis-Token=\(normalized); Oasis-Webid=\(self.webID)", + forHTTPHeaderField: "Cookie") + request.timeoutInterval = self.timeoutSeconds + + let response = try await ProviderHTTPClient.shared.response(for: request) + let data = response.data + guard response.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + Self.log.error("StepFun RefreshToken returned \(response.statusCode): \(body)") + throw StepFunUsageError.tokenRefreshFailed("HTTP \(response.statusCode)") + } + + let decoded: StepFunRefreshTokenResponse + do { + decoded = try JSONDecoder().decode(StepFunRefreshTokenResponse.self, from: data) + } catch { + throw StepFunUsageError.parseFailed("RefreshToken response: \(error.localizedDescription)") + } + + guard let accessToken = decoded.accessToken?.raw, !accessToken.isEmpty else { + throw StepFunUsageError.tokenRefreshFailed("No access token in refresh response") + } + + return self.combinedToken(accessToken: accessToken, refreshToken: decoded.refreshToken?.raw) + } + + private static func combinedToken(accessToken: String, refreshToken: String?) -> String { + guard let refreshToken, !refreshToken.isEmpty else { + return accessToken + } return "\(accessToken)...\(refreshToken)" } @@ -471,7 +530,9 @@ public struct StepFunUsageFetcher: Sendable { } guard decoded.isSuccess else { - let msg = decoded.message ?? decoded.code.map(String.init) ?? "unknown" + let msg = [decoded.message, decoded.desc] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } ?? decoded.code.map(String.init) ?? "unknown" throw StepFunUsageError.apiError(msg) } diff --git a/Sources/CodexBarCore/TokenAccountSupport.swift b/Sources/CodexBarCore/TokenAccountSupport.swift index a6a2b1f6..8e306b90 100644 --- a/Sources/CodexBarCore/TokenAccountSupport.swift +++ b/Sources/CodexBarCore/TokenAccountSupport.swift @@ -12,6 +12,7 @@ public struct TokenAccountSupport: Sendable { public let injection: TokenAccountInjection public let requiresManualCookieSource: Bool public let cookieName: String? + public let environmentKeysToScrub: [String] public init( title: String, @@ -19,7 +20,8 @@ public struct TokenAccountSupport: Sendable { placeholder: String, injection: TokenAccountInjection, requiresManualCookieSource: Bool, - cookieName: String?) + cookieName: String?, + environmentKeysToScrub: [String] = []) { self.title = title self.subtitle = subtitle @@ -27,6 +29,7 @@ public struct TokenAccountSupport: Sendable { self.injection = injection self.requiresManualCookieSource = requiresManualCookieSource self.cookieName = cookieName + self.environmentKeysToScrub = environmentKeysToScrub } } @@ -76,6 +79,9 @@ public enum TokenAccountSupportCatalog { switch support.injection { case let .environment(key): environment.removeValue(forKey: key) + for key in support.environmentKeysToScrub { + environment.removeValue(forKey: key) + } case .cookieHeader: guard provider == .claude else { return } environment.removeValue(forKey: ClaudeOAuthCredentialsStore.environmentTokenKey) diff --git a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift index 8a10f92f..12ec1cee 100644 --- a/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift +++ b/Sources/CodexBarCore/TokenAccountSupportCatalog+Data.swift @@ -8,7 +8,8 @@ extension TokenAccountSupportCatalog { placeholder: "sk-admin-...", injection: .environment(key: OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey), requiresManualCookieSource: false, - cookieName: nil), + cookieName: nil, + environmentKeysToScrub: [OpenAIAPISettingsReader.projectIDEnvironmentKey]), .claude: TokenAccountSupport( title: "Claude credentials", subtitle: "Store Claude sessionKey cookies, OAuth tokens, or Anthropic Admin API keys.", diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index 4bbc3eb5..d77f68e0 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -88,6 +88,7 @@ public struct UsageSnapshot: Codable, Sendable { public let kiroUsage: KiroUsageDetails? public let zaiUsage: ZaiUsageSnapshot? public let minimaxUsage: MiniMaxUsageSnapshot? + public let deepseekUsage: DeepSeekUsageSummary? public let openRouterUsage: OpenRouterUsageSnapshot? public let perplexityUsage: PerplexityUsageSnapshot? public let openAIAPIUsage: OpenAIAPIUsageSnapshot? @@ -144,6 +145,7 @@ public struct UsageSnapshot: Codable, Sendable { providerCost: ProviderCostSnapshot? = nil, zaiUsage: ZaiUsageSnapshot? = nil, minimaxUsage: MiniMaxUsageSnapshot? = nil, + deepseekUsage: DeepSeekUsageSummary? = nil, openRouterUsage: OpenRouterUsageSnapshot? = nil, perplexityUsage: PerplexityUsageSnapshot? = nil, openAIAPIUsage: OpenAIAPIUsageSnapshot? = nil, @@ -168,6 +170,7 @@ public struct UsageSnapshot: Codable, Sendable { self.providerCost = providerCost self.zaiUsage = zaiUsage self.minimaxUsage = minimaxUsage + self.deepseekUsage = deepseekUsage self.openRouterUsage = openRouterUsage self.perplexityUsage = perplexityUsage self.openAIAPIUsage = openAIAPIUsage @@ -195,6 +198,7 @@ public struct UsageSnapshot: Codable, Sendable { self.kiroUsage = try container.decodeIfPresent(KiroUsageDetails.self, forKey: .kiroUsage) self.zaiUsage = nil // Not persisted, fetched fresh each time self.minimaxUsage = nil // Not persisted, fetched fresh each time + self.deepseekUsage = nil // Not persisted, fetched fresh each time self.openRouterUsage = try container.decodeIfPresent(OpenRouterUsageSnapshot.self, forKey: .openRouterUsage) self.perplexityUsage = nil // Not persisted, fetched fresh each time self.openAIAPIUsage = try container.decodeIfPresent(OpenAIAPIUsageSnapshot.self, forKey: .openAIAPIUsage) @@ -348,6 +352,7 @@ public struct UsageSnapshot: Codable, Sendable { providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, + deepseekUsage: self.deepseekUsage, openRouterUsage: self.openRouterUsage, openAIAPIUsage: self.openAIAPIUsage, claudeAdminAPIUsage: self.claudeAdminAPIUsage, @@ -382,6 +387,7 @@ public struct UsageSnapshot: Codable, Sendable { providerCost: self.providerCost, zaiUsage: self.zaiUsage, minimaxUsage: self.minimaxUsage, + deepseekUsage: self.deepseekUsage, openRouterUsage: self.openRouterUsage, openAIAPIUsage: self.openAIAPIUsage, claudeAdminAPIUsage: self.claudeAdminAPIUsage, diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index a1543777..9f0b1b64 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -6,10 +6,75 @@ public enum ResetTimeDisplayStyle: String, Codable, Sendable { } public enum UsageFormatter { + private final class BundleToken {} + + private static let localizationLock = NSLock() + private nonisolated(unsafe) static var localizationProvider: (@Sendable (String) -> String)? + private nonisolated(unsafe) static var localeProvider: (@Sendable () -> Locale)? + + public static func setLocalizationProvider(_ provider: @escaping @Sendable (String) -> String) { + self.localizationLock.lock() + self.localizationProvider = provider + self.localizationLock.unlock() + } + + public static func clearLocalizationProvider() { + self.localizationLock.lock() + self.localizationProvider = nil + self.localizationLock.unlock() + } + + public static func setLocaleProvider(_ provider: @escaping @Sendable () -> Locale) { + self.localizationLock.lock() + self.localeProvider = provider + self.localizationLock.unlock() + } + + public static func clearLocaleProvider() { + self.localizationLock.lock() + self.localeProvider = nil + self.localizationLock.unlock() + } + + private static func currentLocale() -> Locale { + self.localizationLock.lock() + let provider = self.localeProvider + self.localizationLock.unlock() + return provider?() ?? Locale(identifier: "en_US_POSIX") + } + + private static func localized(_ key: String) -> String { + self.localizationLock.lock() + let provider = self.localizationProvider + self.localizationLock.unlock() + if let provider { + return provider(key) + } + let coreBundle = Bundle(for: BundleToken.self) + let coreValue = NSLocalizedString(key, tableName: "Localizable", bundle: coreBundle, value: key, comment: "") + if coreValue != key { return coreValue } + + let mainValue = NSLocalizedString(key, tableName: "Localizable", bundle: .main, value: key, comment: "") + if mainValue != key { return mainValue } + + switch key { + case "usage_percent_suffix_left": return "left" + case "usage_percent_suffix_used": return "used" + default: return key + } + } + + private static func localized(_ key: String, _ args: CVarArg...) -> String { + let format = self.localized(key) + return String(format: format, locale: self.currentLocale(), arguments: args) + } + public static func usageLine(remaining: Double, used: Double, showUsed: Bool) -> String { let percent = showUsed ? used : remaining let clamped = min(100, max(0, percent)) - let suffix = showUsed ? "used" : "left" + let suffix = showUsed + ? self.localized("usage_percent_suffix_used") + : self.localized("usage_percent_suffix_left") return String(format: "%.0f%% %@", clamped, suffix) } @@ -37,14 +102,14 @@ public enum UsageFormatter { // Human-friendly phrasing: today / tomorrow / date+time. let calendar = Calendar.current if calendar.isDate(date, inSameDayAs: now) { - return date.formatted(date: .omitted, time: .shortened) + return date.formatted(.dateTime.hour().minute().locale(self.currentLocale())) } if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), calendar.isDate(date, inSameDayAs: tomorrow) { - return "tomorrow, \(date.formatted(date: .omitted, time: .shortened))" + return "tomorrow, \(date.formatted(.dateTime.hour().minute().locale(self.currentLocale())))" } - return date.formatted(date: .abbreviated, time: .shortened) + return date.formatted(.dateTime.month(.abbreviated).day().hour().minute().locale(self.currentLocale())) } public static func resetLine( @@ -53,17 +118,30 @@ public enum UsageFormatter { now: Date = .init()) -> String? { if let date = window.resetsAt { - let text = style == .countdown - ? self.resetCountdownDescription(from: date, now: now) - : self.resetDescription(from: date, now: now) - return "Resets \(text)" + if style == .countdown { + let countdown = self.resetCountdownDescription(from: date, now: now) + if countdown == "now" { + return self.localized("Resets now") + } + if countdown.hasPrefix("in ") { + return self.localized("Resets in %@", String(countdown.dropFirst(3))) + } + return self.localized("Resets %@", countdown) + } + let text = self.resetDescription(from: date, now: now) + return self.localized("Resets %@", text) } if let desc = window.resetDescription { let trimmed = desc.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } - if trimmed.lowercased().hasPrefix("resets") { return trimmed } - return "Resets \(trimmed)" + if trimmed.lowercased().hasPrefix("resets in ") { + return self.localized("Resets in %@", String(trimmed.dropFirst("Resets in ".count))) + } + if trimmed.lowercased().hasPrefix("resets ") { + return self.localized("Resets %@", String(trimmed.dropFirst("Resets ".count))) + } + return self.localized("Resets %@", trimmed) } return nil } @@ -71,25 +149,27 @@ public enum UsageFormatter { public static func updatedString(from date: Date, now: Date = .init()) -> String { let delta = now.timeIntervalSince(date) if abs(delta) < 60 { - return "Updated just now" + return self.localized("Updated just now") } if let hours = Calendar.current.dateComponents([.hour], from: date, to: now).hour, hours < 24 { #if os(macOS) let rel = RelativeDateTimeFormatter() - rel.locale = Locale(identifier: "en_US") + rel.locale = self.currentLocale() rel.unitsStyle = .abbreviated - return "Updated \(rel.localizedString(for: date, relativeTo: now))" + return self.localized("Updated %@", rel.localizedString(for: date, relativeTo: now)) #else let seconds = max(0, Int(now.timeIntervalSince(date))) if seconds < 3600 { let minutes = max(1, seconds / 60) - return "Updated \(minutes)m ago" + return self.localized("Updated %@m ago", String(minutes)) } let wholeHours = max(1, seconds / 3600) - return "Updated \(wholeHours)h ago" + return self.localized("Updated %@h ago", String(wholeHours)) #endif } else { - return "Updated \(date.formatted(date: .omitted, time: .shortened))" + return self.localized( + "Updated %@", + date.formatted(.dateTime.hour().minute().locale(self.currentLocale()))) } } @@ -100,7 +180,7 @@ public enum UsageFormatter { // Use explicit locale for consistent formatting on all systems number.locale = Locale(identifier: "en_US_POSIX") let formatted = number.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) - return "\(formatted) left" + return self.localized("%@ left", formatted) } public static func kiroCreditNumber(_ value: Double) -> String { @@ -246,11 +326,16 @@ public enum UsageFormatter { return cleaned.isEmpty ? raw : cleaned } - public static func modelCostDetail(_ model: String, costUSD: Double?, totalTokens: Int? = nil) -> String? { + public static func modelCostDetail( + _ model: String, + costUSD: Double?, + totalTokens: Int? = nil, + currencyCode: String = "USD") -> String? + { let costDetail: String? = if let label = CostUsagePricing.codexDisplayLabel(model: model) { label } else if let costUSD { - self.usdString(costUSD) + self.currencyString(costUSD, currencyCode: currencyCode) } else { nil } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift index 2bf74841..7e13a918 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageJsonl.swift @@ -14,6 +14,25 @@ enum CostUsageJsonl { prefixBytes: Int, onLine: (Line) -> Void) throws -> Int64 + { + try self.scan( + fileURL: fileURL, + offset: offset, + maxLineBytes: maxLineBytes, + prefixBytes: prefixBytes, + checkCancellation: nil, + onLine: onLine) + } + + @discardableResult + static func scan( + fileURL: URL, + offset: Int64 = 0, + maxLineBytes: Int, + prefixBytes: Int, + checkCancellation: (() throws -> Void)? = nil, + onLine: (Line) -> Void) throws + -> Int64 { let handle = try FileHandle(forReadingFrom: fileURL) defer { try? handle.close() } @@ -53,12 +72,14 @@ enum CostUsageJsonl { } while true { + try checkCancellation?() let chunk = try handle.read(upToCount: 256 * 1024) ?? Data() if chunk.isEmpty { flushLine() break } + try checkCancellation?() bytesRead += Int64(chunk.count) chunk.withUnsafeBytes { rawBuffer in guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } @@ -76,6 +97,7 @@ enum CostUsageJsonl { appendSegment(base.advanced(by: segmentStart), count: rawBuffer.count - segmentStart) } } + try checkCancellation?() } return startOffset + bytesRead diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift index faa5533f..d8aefbbf 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsagePricing.swift @@ -275,6 +275,16 @@ enum CostUsagePricing { outputCostPerTokenAboveThreshold: nil, cacheCreationInputCostPerTokenAboveThreshold: nil, cacheReadInputCostPerTokenAboveThreshold: nil), + "claude-opus-4-8": ClaudePricing( + inputCostPerToken: 5e-6, + outputCostPerToken: 2.5e-5, + cacheCreationInputCostPerToken: 6.25e-6, + cacheReadInputCostPerToken: 5e-7, + thresholdTokens: nil, + inputCostPerTokenAboveThreshold: nil, + outputCostPerTokenAboveThreshold: nil, + cacheCreationInputCostPerTokenAboveThreshold: nil, + cacheReadInputCostPerTokenAboveThreshold: nil), "claude-sonnet-4-5": ClaudePricing( inputCostPerToken: 3e-6, outputCostPerToken: 1.5e-5, @@ -353,6 +363,8 @@ enum CostUsagePricing { /// `CostUsageJsonl.swift` change vs origin/mobile-dev. /// /// History: + /// - `4` (0.32.1): merged upstream v0.32.1 parser changes, including + /// expanded Codex/Claude event handling and model normalization. /// - `3` (0.29.0): merged upstream v0.28.0+v0.29.0 Codex cost-scanner /// changes — standard vs fast spend/token splits in model breakdowns /// (#1070) and no-recount of repeated local token snapshots when total @@ -367,7 +379,7 @@ enum CostUsagePricing { /// in `parseCodexFile`. Bumping rolls every previous version's /// cache and re-scans with the fixed parser. /// - `1` (0.23.1): initial fingerprint contract. - static let parserLogicVersion = 3 + static let parserLogicVersion = 4 /// Stable string fingerprint of the pricing tables + parser logic. /// `CostUsageCacheIO.load` compares this against the value stored diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift index 2b434973..64d351eb 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+CacheHelpers.swift @@ -697,8 +697,9 @@ extension CostUsageScanner { input: CodexFileScanInput, context: CodexFileScanContext, cache: inout CostUsageCache, - state: inout CodexScanState) -> Bool + state: inout CodexScanState) throws -> Bool { + try context.checkCancellation?() guard let cached = input.cached, cached.sessionId != nil, !context.forceFullScan else { return false } guard !Self.cachedCodexFileNeedsPriorityRescan(cached, context: context) else { return false } let startOffset = cached.parsedBytes ?? cached.size @@ -710,7 +711,7 @@ extension CostUsageScanner { && cached.forkedFromId == nil guard canIncremental else { return false } - let delta = Self.parseCodexFile( + let delta = try Self.parseCodexFileCancellable( fileURL: input.fileURL, range: context.range, startOffset: startOffset, @@ -718,7 +719,11 @@ extension CostUsageScanner { initialTotals: initialCountedTotals, initialRawTotalsBaseline: initialRawTotalsBaseline, initialHasDivergentTotals: cached.hasDivergentTotals ?? (cached.lastTotals == nil), - initialCodexTurnID: cached.lastCodexTurnID) + initialCodexTurnID: cached.lastCodexTurnID, + checkCancellation: context.checkCancellation) + if delta.forkedFromId != nil { + return false + } let sessionId = delta.sessionId ?? cached.sessionId if let sessionId, state.seenSessionIds.contains(sessionId) { Self.dropCachedCodexFile(path: input.metadata.path, cached: cached, cache: &cache) @@ -786,8 +791,9 @@ extension CostUsageScanner { input: CodexFileScanInput, context: CodexFileScanContext, cache: inout CostUsageCache, - state: inout CodexScanState) + state: inout CodexScanState) throws { + try context.checkCancellation?() if let cached = input.cached { self.applyFileDays(cache: &cache, fileDays: cached.days, sign: -1) } @@ -796,10 +802,11 @@ extension CostUsageScanner { ? [:] : Self.fileDaysOutsideScanWindow(migratedCached?.days ?? [:], range: context.range) - let parsed = Self.parseCodexFile( + let parsed = try Self.parseCodexFileCancellable( fileURL: input.fileURL, range: context.range, - inheritedTotalsResolver: context.resources.inheritedResolver.inheritedTotals(for:atOrBefore:)) + inheritedTotalsResolver: context.resources.inheritedResolver.inheritedTotals(for:atOrBefore:), + checkCancellation: context.checkCancellation) let sessionId = parsed.sessionId ?? input.cached?.sessionId if let sessionId, state.seenSessionIds.contains(sessionId) { cache.files.removeValue(forKey: input.metadata.path) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 6e56a885..3d1578e3 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -38,6 +38,26 @@ extension CostUsageScanner { startOffset: Int64 = 0, modelsDevCatalog: ModelsDevCatalog? = nil, modelsDevCacheRoot: URL? = nil) -> ClaudeParseResult + { + ( + try? self.parseClaudeFileCancellable( + fileURL: fileURL, + range: range, + providerFilter: providerFilter, + startOffset: startOffset, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot, + checkCancellation: nil)) ?? ClaudeParseResult(days: [:], rows: [], parsedBytes: startOffset) + } + + static func parseClaudeFileCancellable( + fileURL: URL, + range: CostUsageDayRange, + providerFilter: ClaudeLogProviderFilter, + startOffset: Int64 = 0, + modelsDevCatalog: ModelsDevCatalog? = nil, + modelsDevCacheRoot: URL? = nil, + checkCancellation: CancellationCheck? = nil) throws -> ClaudeParseResult { struct ClaudeTokens: Sendable { let input: Int @@ -85,95 +105,103 @@ extension CostUsageScanner { let prefixBytes = maxLineBytes let costScale = 1_000_000_000.0 - let parsedBytes = (try? CostUsageJsonl.scan( - fileURL: fileURL, - offset: startOffset, - maxLineBytes: maxLineBytes, - prefixBytes: prefixBytes, - onLine: { line in - guard !line.bytes.isEmpty else { return } - guard !line.wasTruncated else { return } - guard line.bytes.containsAscii(#""type":"assistant""#) else { return } - guard line.bytes.containsAscii(#""usage""#) else { return } - - autoreleasepool { - guard - let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], - let type = obj["type"] as? String, - type == "assistant" - else { return } - guard Self.matchesClaudeProviderFilter(obj: obj, filter: providerFilter) else { return } - - guard let tsText = obj["timestamp"] as? String else { return } - guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) - else { return } - - guard let message = obj["message"] as? [String: Any] else { return } - guard let model = message["model"] as? String else { return } - guard let usage = message["usage"] as? [String: Any] else { return } - - let input = max(0, toInt(usage["input_tokens"])) - let cacheCreate = max(0, toInt(usage["cache_creation_input_tokens"])) - let cacheRead = max(0, toInt(usage["cache_read_input_tokens"])) - let output = max(0, toInt(usage["output_tokens"])) - if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { return } - - let cost = CostUsagePricing.claudeCostUSD( - model: model, - inputTokens: input, - cacheReadInputTokens: cacheRead, - cacheCreationInputTokens: cacheCreate, - outputTokens: output, - modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: modelsDevCacheRoot) - let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 - let tokens = ClaudeTokens( - input: input, - cacheRead: cacheRead, - cacheCreate: cacheCreate, - output: output, - costNanos: costNanos, - costPriced: cost != nil) - - guard CostUsageDayRange.isInRange( - dayKey: dayKey, - since: range.scanSinceKey, - until: range.scanUntilKey) - else { return } - - let messageId = message["id"] as? String - let requestId = obj["requestId"] as? String - let sessionId = obj["sessionId"] as? String - ?? obj["session_id"] as? String - ?? (obj["metadata"] as? [String: Any])?["sessionId"] as? String - ?? (message["metadata"] as? [String: Any])?["sessionId"] as? String - let normalizedModel = CostUsagePricing.normalizeClaudeModel(model) - let row = ClaudeUsageRow( - dayKey: dayKey, - model: normalizedModel, - sessionId: sessionId, - messageId: messageId, - requestId: requestId, - isSidechain: toBool(obj["isSidechain"]), - pathRole: pathRole, - input: tokens.input, - cacheRead: tokens.cacheRead, - cacheCreate: tokens.cacheCreate, - output: tokens.output, - costNanos: tokens.costNanos, - costPriced: tokens.costPriced) - - // Streaming chunks share message.id + requestId inside a file. - // Keep overwriting so the final cumulative chunk wins. - if let messageId, let requestId { - let key = "\(messageId):\(requestId)" - keyedRows[key] = row - } else { - // Older logs omit IDs; treat each line as distinct to avoid dropping usage. - unkeyedRows.append(row) + let parsedBytes: Int64 + do { + parsedBytes = try CostUsageJsonl.scan( + fileURL: fileURL, + offset: startOffset, + maxLineBytes: maxLineBytes, + prefixBytes: prefixBytes, + checkCancellation: checkCancellation, + onLine: { line in + guard !line.bytes.isEmpty else { return } + guard !line.wasTruncated else { return } + guard line.bytes.containsAscii(#""type":"assistant""#) else { return } + guard line.bytes.containsAscii(#""usage""#) else { return } + + autoreleasepool { + guard + let obj = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any], + let type = obj["type"] as? String, + type == "assistant" + else { return } + guard Self.matchesClaudeProviderFilter(obj: obj, filter: providerFilter) else { return } + + guard let tsText = obj["timestamp"] as? String else { return } + guard let dayKey = Self.dayKeyFromTimestamp(tsText) ?? Self.dayKeyFromParsedISO(tsText) + else { return } + + guard let message = obj["message"] as? [String: Any] else { return } + guard let model = message["model"] as? String else { return } + guard let usage = message["usage"] as? [String: Any] else { return } + + let input = max(0, toInt(usage["input_tokens"])) + let cacheCreate = max(0, toInt(usage["cache_creation_input_tokens"])) + let cacheRead = max(0, toInt(usage["cache_read_input_tokens"])) + let output = max(0, toInt(usage["output_tokens"])) + if input == 0, cacheCreate == 0, cacheRead == 0, output == 0 { return } + + let cost = CostUsagePricing.claudeCostUSD( + model: model, + inputTokens: input, + cacheReadInputTokens: cacheRead, + cacheCreationInputTokens: cacheCreate, + outputTokens: output, + modelsDevCatalog: modelsDevCatalog, + modelsDevCacheRoot: modelsDevCacheRoot) + let costNanos = cost.map { Int(($0 * costScale).rounded()) } ?? 0 + let tokens = ClaudeTokens( + input: input, + cacheRead: cacheRead, + cacheCreate: cacheCreate, + output: output, + costNanos: costNanos, + costPriced: cost != nil) + + guard CostUsageDayRange.isInRange( + dayKey: dayKey, + since: range.scanSinceKey, + until: range.scanUntilKey) + else { return } + + let messageId = message["id"] as? String + let requestId = obj["requestId"] as? String + let sessionId = obj["sessionId"] as? String + ?? obj["session_id"] as? String + ?? (obj["metadata"] as? [String: Any])?["sessionId"] as? String + ?? (message["metadata"] as? [String: Any])?["sessionId"] as? String + let normalizedModel = CostUsagePricing.normalizeClaudeModel(model) + let row = ClaudeUsageRow( + dayKey: dayKey, + model: normalizedModel, + sessionId: sessionId, + messageId: messageId, + requestId: requestId, + isSidechain: toBool(obj["isSidechain"]), + pathRole: pathRole, + input: tokens.input, + cacheRead: tokens.cacheRead, + cacheCreate: tokens.cacheCreate, + output: tokens.output, + costNanos: tokens.costNanos, + costPriced: tokens.costPriced) + + // Streaming chunks share message.id + requestId inside a file. + // Keep overwriting so the final cumulative chunk wins. + if let messageId, let requestId { + let key = "\(messageId):\(requestId)" + keyedRows[key] = row + } else { + // Older logs omit IDs; treat each line as distinct to avoid dropping usage. + unkeyedRows.append(row) + } } - } - })) ?? startOffset + }) + } catch is CancellationError { + throw CancellationError() + } catch { + parsedBytes = startOffset + } let rows = keyedRows.keys.sorted().compactMap { keyedRows[$0] } + unkeyedRows var days: [String: [String: [Int]]] = [:] @@ -429,6 +457,7 @@ extension CostUsageScanner { let forceFullScan: Bool let modelsDevCatalog: ModelsDevCatalog? let modelsDevCacheRoot: URL? + let checkCancellation: CancellationCheck? init( cache: CostUsageCache, @@ -436,7 +465,8 @@ extension CostUsageScanner { providerFilter: ClaudeLogProviderFilter, forceFullScan: Bool, modelsDevCatalog: ModelsDevCatalog?, - modelsDevCacheRoot: URL?) + modelsDevCacheRoot: URL?, + checkCancellation: CancellationCheck?) { self.cache = cache self.touched = [] @@ -445,6 +475,7 @@ extension CostUsageScanner { self.forceFullScan = forceFullScan self.modelsDevCatalog = modelsDevCatalog self.modelsDevCacheRoot = modelsDevCacheRoot + self.checkCancellation = checkCancellation } } @@ -452,8 +483,9 @@ extension CostUsageScanner { url: URL, size: Int64, mtimeMs: Int64, - state: ClaudeScanState) + state: ClaudeScanState) throws { + try state.checkCancellation?() let path = url.path state.touched.insert(path) @@ -470,13 +502,14 @@ extension CostUsageScanner { let canIncremental = size > cached.size && startOffset > 0 && startOffset <= size && cached.claudeRows != nil if canIncremental { - let delta = Self.parseClaudeFile( + let delta = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, providerFilter: state.providerFilter, startOffset: startOffset, modelsDevCatalog: state.modelsDevCatalog, - modelsDevCacheRoot: state.modelsDevCacheRoot) + modelsDevCacheRoot: state.modelsDevCacheRoot, + checkCancellation: state.checkCancellation) let mergedRows = Self.mergeClaudeRows(existing: cached.claudeRows ?? [], delta: delta.rows) state.cache.files[path] = Self.makeClaudeFileUsage( mtimeMs: mtimeMs, @@ -487,12 +520,13 @@ extension CostUsageScanner { } } - let parsed = Self.parseClaudeFile( + let parsed = try Self.parseClaudeFileCancellable( fileURL: url, range: state.range, providerFilter: state.providerFilter, modelsDevCatalog: state.modelsDevCatalog, - modelsDevCacheRoot: state.modelsDevCacheRoot) + modelsDevCacheRoot: state.modelsDevCacheRoot, + checkCancellation: state.checkCancellation) let usage = Self.makeClaudeFileUsage( mtimeMs: mtimeMs, size: size, @@ -503,8 +537,9 @@ extension CostUsageScanner { private static func scanClaudeRoot( root: URL, - state: ClaudeScanState) + state: ClaudeScanState) throws { + try state.checkCancellation?() let rootPath = root.path let rootCandidates = Self.claudeRootCandidates(for: rootPath) let prefixes = Set(rootCandidates).map { path in @@ -542,6 +577,7 @@ extension CostUsageScanner { else { return } for case let url as URL in enumerator { + try state.checkCancellation?() guard url.pathExtension.lowercased() == "jsonl" else { continue } guard let values = try? url.resourceValues(forKeys: Set(keys)) else { continue } guard values.isRegularFile == true else { continue } @@ -550,7 +586,7 @@ extension CostUsageScanner { let mtime = values.contentModificationDate?.timeIntervalSince1970 ?? 0 let mtimeMs = Int64(mtime * 1000) - Self.processClaudeFile( + try Self.processClaudeFile( url: url, size: size, mtimeMs: mtimeMs, @@ -564,7 +600,8 @@ extension CostUsageScanner { provider: UsageProvider, range: CostUsageDayRange, now: Date, - options: Options) -> CostUsageDailyReport + options: Options, + checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { var cache = CostUsageCacheIO.load(provider: provider, cacheRoot: options.cacheRoot) let nowMs = Int64(now.timeIntervalSince1970 * 1000) @@ -583,6 +620,7 @@ extension CostUsageScanner { var touched: Set = [] if shouldRefresh { + try checkCancellation?() if options.forceRescan { cache = CostUsageCache() } @@ -593,13 +631,15 @@ extension CostUsageScanner { providerFilter: providerFilter, forceFullScan: options.forceRescan || windowExpanded, modelsDevCatalog: modelsDevCatalog, - modelsDevCacheRoot: options.cacheRoot) + modelsDevCacheRoot: options.cacheRoot, + checkCancellation: checkCancellation) for root in roots { - Self.scanClaudeRoot( + try Self.scanClaudeRoot( root: root, state: scanState) } + try checkCancellation?() cache = scanState.cache touched = scanState.touched @@ -614,6 +654,7 @@ extension CostUsageScanner { cache.scanSinceKey = range.scanSinceKey cache.scanUntilKey = range.scanUntilKey cache.lastScanUnixMs = nowMs + try checkCancellation?() CostUsageCacheIO.save(provider: provider, cache: cache, cacheRoot: options.cacheRoot) } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 90161bf5..a70e98c8 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -5,8 +5,10 @@ import Crypto #endif import Foundation -// swiftlint:disable type_body_length +// swiftlint:disable type_body_length file_length enum CostUsageScanner { + typealias CancellationCheck = () throws -> Void + static let log = CodexBarLog.logger(LogCategories.tokenCost) static let codexActiveSessionLookbackDays = 30 static let costScale = 1_000_000_000.0 @@ -46,7 +48,7 @@ enum CostUsageScanner { struct CodexParseResult { let days: [String: [String: [Int]]] - let parsedBytes: Int64 + var parsedBytes: Int64 let lastModel: String? let lastTotals: CostUsageCodexTotals? let lastCountedTotals: CostUsageCodexTotals? @@ -78,6 +80,11 @@ enum CostUsageScanner { let totals: CostUsageCodexTotals } + enum CodexForkBaseline { + case resolved(CostUsageCodexTotals?) + case unresolved + } + private static func codexTotalsEqual(_ lhs: CostUsageCodexTotals?, _ rhs: CostUsageCodexTotals?) -> Bool { lhs?.input == rhs?.input && lhs?.cached == rhs?.cached && lhs?.output == rhs?.output } @@ -112,6 +119,16 @@ enum CostUsageScanner { output: lhs.output + rhs.output) } + private static func codexMinTotals( + _ lhs: CostUsageCodexTotals, + _ rhs: CostUsageCodexTotals) -> CostUsageCodexTotals + { + CostUsageCodexTotals( + input: min(lhs.input, rhs.input), + cached: min(lhs.cached, rhs.cached), + output: min(lhs.output, rhs.output)) + } + private static func codexTotalDelta( from baseline: CostUsageCodexTotals?, to current: CostUsageCodexTotals) -> CostUsageCodexTotals @@ -159,6 +176,7 @@ enum CostUsageScanner { let requiresTurnIDCache: Bool let changedPriorityTurnIDs: Set let resources: CodexScanResources + let checkCancellation: CancellationCheck? } struct CodexRefreshPlan { @@ -187,16 +205,23 @@ enum CostUsageScanner { private let files: [URL] private let filePaths: Set private let roots: [URL] + private let checkCancellation: CancellationCheck? private var nextUnindexedFile = 0 private var didIndexRoots = false private var fileURLBySessionId: [String: URL] = [:] private var missingSessionIds: Set = [] - init(files: [URL], roots: [URL], cachedSessionFiles: [String: URL] = [:]) { + init( + files: [URL], + roots: [URL], + cachedSessionFiles: [String: URL] = [:], + checkCancellation: CancellationCheck? = nil) + { self.files = files self.filePaths = Set(files.map(\.path)) self.roots = roots self.fileURLBySessionId = cachedSessionFiles + self.checkCancellation = checkCancellation } func remember(fileURL: URL, sessionId: String?) { @@ -204,7 +229,7 @@ enum CostUsageScanner { self.fileURLBySessionId[sessionId] = fileURL } - func fileURL(for sessionId: String) -> URL? { + func fileURL(for sessionId: String) throws -> URL? { if let cached = self.fileURLBySessionId[sessionId] { return cached } @@ -213,9 +238,13 @@ enum CostUsageScanner { } while self.nextUnindexedFile < self.files.count { + try self.checkCancellation?() let fileURL = self.files[self.nextUnindexedFile] self.nextUnindexedFile += 1 - guard let indexedSessionId = CostUsageScanner.parseCodexSessionIdentifier(fileURL: fileURL) else { + guard let indexedSessionId = try CostUsageScanner.parseCodexSessionIdentifier( + fileURL: fileURL, + checkCancellation: self.checkCancellation) + else { continue } self.fileURLBySessionId[indexedSessionId] = fileURL @@ -225,7 +254,7 @@ enum CostUsageScanner { } if !self.didIndexRoots { - self.indexRoots() + try self.indexRoots() if let indexed = self.fileURLBySessionId[sessionId] { return indexed } @@ -235,10 +264,11 @@ enum CostUsageScanner { return nil } - private func indexRoots() { + private func indexRoots() throws { self.didIndexRoots = true guard !self.roots.isEmpty else { return } for root in self.roots { + try self.checkCancellation?() guard let enumerator = FileManager.default.enumerator( at: root, includingPropertiesForKeys: [.isRegularFileKey], @@ -246,9 +276,13 @@ enum CostUsageScanner { else { continue } while let fileURL = enumerator.nextObject() as? URL { + try self.checkCancellation?() guard fileURL.pathExtension.lowercased() == "jsonl" else { continue } guard !self.filePaths.contains(fileURL.path) else { continue } - guard let indexedSessionId = CostUsageScanner.parseCodexSessionIdentifier(fileURL: fileURL) else { + guard let indexedSessionId = try CostUsageScanner.parseCodexSessionIdentifier( + fileURL: fileURL, + checkCancellation: self.checkCancellation) + else { continue } self.fileURLBySessionId[indexedSessionId] = fileURL @@ -259,21 +293,28 @@ enum CostUsageScanner { final class CodexInheritedTotalsResolver { private let fileIndex: CodexSessionFileIndex + private let checkCancellation: CancellationCheck? private var snapshotsBySessionId: [String: [CodexTimestampedTotals]] = [:] - init(fileIndex: CodexSessionFileIndex) { + init(fileIndex: CodexSessionFileIndex, checkCancellation: CancellationCheck?) { self.fileIndex = fileIndex + self.checkCancellation = checkCancellation } - func inheritedTotals(for sessionId: String, atOrBefore cutoffTimestamp: String) -> CostUsageCodexTotals? { - guard !cutoffTimestamp.isEmpty else { return nil } + func inheritedTotals(for sessionId: String, atOrBefore cutoffTimestamp: String) throws -> CodexForkBaseline { + guard !cutoffTimestamp.isEmpty else { + CostUsageScanner.log.warning( + "Codex cost usage fork timestamp missing; treating parent baseline as unresolved", + metadata: ["sessionId": sessionId]) + return .unresolved + } let cutoffDate = CostUsageScanner.dateFromTimestamp(cutoffTimestamp) if cutoffDate == nil { CostUsageScanner.log.warning( "Codex cost usage could not parse fork timestamp; falling back to lexical comparison", metadata: ["sessionId": sessionId, "timestamp": cutoffTimestamp]) } - let snapshots = self.snapshots(for: sessionId) + guard let snapshots = try self.snapshots(for: sessionId) else { return .unresolved } var inherited: CostUsageCodexTotals? for snapshot in snapshots { let isAtOrBefore: Bool = if let snapshotDate = snapshot.date, let cutoffDate { @@ -285,25 +326,28 @@ enum CostUsageScanner { inherited = snapshot.totals } } - return inherited + return .resolved(inherited) } - private func snapshots(for sessionId: String) -> [CodexTimestampedTotals] { + private func snapshots(for sessionId: String) throws -> [CodexTimestampedTotals]? { if let cached = self.snapshotsBySessionId[sessionId] { return cached } - guard let fileURL = self.fileIndex.fileURL(for: sessionId) else { + try self.checkCancellation?() + guard let fileURL = try self.fileIndex.fileURL(for: sessionId) else { CostUsageScanner.log.warning( "Codex cost usage parent session file not found", metadata: ["sessionId": sessionId]) - return [] + return nil } - let parsed = CostUsageScanner.parseCodexTokenSnapshots(fileURL: fileURL) + let parsed = try CostUsageScanner.parseCodexTokenSnapshots( + fileURL: fileURL, + checkCancellation: self.checkCancellation) guard let parsedSessionId = parsed.sessionId else { CostUsageScanner.log.warning( "Codex cost usage parent session missing session metadata", metadata: ["sessionId": sessionId, "path": fileURL.path]) - return [] + return nil } if parsedSessionId != sessionId { CostUsageScanner.log.warning( @@ -313,9 +357,10 @@ enum CostUsageScanner { "resolvedSessionId": parsedSessionId, "path": fileURL.path, ]) + return nil } - self.snapshotsBySessionId[parsedSessionId] = parsed.snapshots - return self.snapshotsBySessionId[sessionId] ?? [] + self.snapshotsBySessionId[sessionId] = parsed.snapshots + return parsed.snapshots } } @@ -352,21 +397,54 @@ enum CostUsageScanner { until: Date, now: Date = Date(), options: Options = Options()) -> CostUsageDailyReport + { + ( + try? self.loadDailyReportCancellable( + provider: provider, + since: since, + until: until, + now: now, + options: options, + checkCancellation: nil)) ?? CostUsageDailyReport(data: [], summary: nil) + } + + static func loadDailyReportCancellable( + provider: UsageProvider, + since: Date, + until: Date, + now: Date = Date(), + options: Options = Options(), + checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport { let range = CostUsageDayRange(since: since, until: until) let emptyReport = CostUsageDailyReport(data: [], summary: nil) + try checkCancellation?() switch provider { case .codex: - return self.loadCodexDaily(range: range, now: now, options: options) + return try self.loadCodexDaily( + range: range, + now: now, + options: options, + checkCancellation: checkCancellation) case .claude: - return self.loadClaudeDaily(provider: .claude, range: range, now: now, options: options) + return try self.loadClaudeDaily( + provider: .claude, + range: range, + now: now, + options: options, + checkCancellation: checkCancellation) case .vertexai: var filtered = options if filtered.claudeLogProviderFilter == .all { filtered.claudeLogProviderFilter = .vertexAIOnly } - return self.loadClaudeDaily(provider: .vertexai, range: range, now: now, options: filtered) + return try self.loadClaudeDaily( + provider: .vertexai, + range: range, + now: now, + options: filtered, + checkCancellation: checkCancellation) case .openai, .azureopenai, .zai, .gemini, .antigravity, .cursor, .opencode, .opencodego, .alibaba, .alibabatokenplan, .factory, .copilot, .minimax, .manus, .kilo, .kiro, .kimi, .kimik2, .moonshot, .augment, .jetbrains, .amp, .ollama, @@ -494,6 +572,10 @@ enum CostUsageScanner { return out } + static func codexRootsFingerprint(options: Options) -> [String: Int64] { + self.codexRootsFingerprint(self.codexSessionsRoots(options: options)) + } + private static func codexPricingKey(modelsDevArtifact: ModelsDevCacheArtifact?) -> String { guard let modelsDevArtifact else { let fingerprint = CostUsagePricing.codexBuiltInPricingFingerprint() @@ -854,11 +936,29 @@ enum CostUsageScanner { let forkTimestamp: String? } - private static func parseCodexSessionIdentifier(fileURL: URL) -> String? { - self.parseCodexSessionMetadata(fileURL: fileURL)?.sessionId + private static func codexForkParentId(from payload: [String: Any]?) -> String? { + guard let payload else { return nil } + for key in ["forked_from_id", "forkedFromId", "parent_session_id", "parentSessionId"] { + guard let value = payload[key] as? String else { continue } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + return nil + } + + private static func parseCodexSessionIdentifier( + fileURL: URL, + checkCancellation: CancellationCheck? = nil) throws -> String? + { + try self.parseCodexSessionMetadata(fileURL: fileURL, checkCancellation: checkCancellation)?.sessionId } - private static func parseCodexSessionMetadata(fileURL: URL) -> CodexSessionMetadata? { + private static func parseCodexSessionMetadata( + fileURL: URL, + checkCancellation: CancellationCheck? = nil) throws -> CodexSessionMetadata? + { let handle: FileHandle do { handle = try FileHandle(forReadingFrom: fileURL) @@ -887,10 +987,7 @@ enum CostUsageScanner { ?? obj["session_id"] as? String ?? obj["sessionId"] as? String ?? obj["id"] as? String, - forkedFromId: payload?["forked_from_id"] as? String - ?? payload?["forkedFromId"] as? String - ?? payload?["parent_session_id"] as? String - ?? payload?["parentSessionId"] as? String, + forkedFromId: Self.codexForkParentId(from: payload), forkTimestamp: payload?["timestamp"] as? String ?? obj["timestamp"] as? String) } @@ -898,6 +995,7 @@ enum CostUsageScanner { do { while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty { + try checkCancellation?() buffer.append(chunk) while let newlineRange = buffer.range(of: newline) { let lineData = buffer.subdata(in: 0.. ( + private static func parseCodexTokenSnapshots( + fileURL: URL, + checkCancellation: CancellationCheck? = nil) throws -> ( sessionId: String?, snapshots: [CodexTimestampedTotals]) { @@ -948,6 +1050,7 @@ enum CostUsageScanner { fileURL: fileURL, maxLineBytes: 512 * 1024, prefixBytes: 512 * 1024, + checkCancellation: checkCancellation, onLine: { line in guard !line.bytes.isEmpty, !line.wasTruncated else { return } autoreleasepool { @@ -1045,6 +1148,8 @@ enum CostUsageScanner { } } }) + } catch is CancellationError { + throw CancellationError() } catch { self.log.warning( "Codex cost usage failed while scanning parent token snapshots", @@ -1054,7 +1159,6 @@ enum CostUsageScanner { return (sessionId, snapshots) } - // swiftlint:disable:next cyclomatic_complexity function_body_length static func parseCodexFile( fileURL: URL, range: CostUsageDayRange, @@ -1064,7 +1168,49 @@ enum CostUsageScanner { initialRawTotalsBaseline: CostUsageCodexTotals? = nil, initialHasDivergentTotals: Bool = false, initialCodexTurnID: String? = nil, - inheritedTotalsResolver: ((String, String) -> CostUsageCodexTotals?)? = nil) -> CodexParseResult + inheritedTotalsResolver: ((String, String) -> CodexForkBaseline)? = nil) -> CodexParseResult + { + let throwingResolver: ((String, String) throws -> CodexForkBaseline)? = inheritedTotalsResolver + .map { resolver in + { sessionId, timestamp in resolver(sessionId, timestamp) } + } + return ( + try? Self.parseCodexFileCancellable( + fileURL: fileURL, + range: range, + startOffset: startOffset, + initialModel: initialModel, + initialTotals: initialTotals, + initialRawTotalsBaseline: initialRawTotalsBaseline, + initialHasDivergentTotals: initialHasDivergentTotals, + initialCodexTurnID: initialCodexTurnID, + inheritedTotalsResolver: throwingResolver, + checkCancellation: nil)) ?? CodexParseResult( + days: [:], + parsedBytes: startOffset, + lastModel: initialModel, + lastTotals: initialTotals, + lastCountedTotals: initialTotals, + lastRawTotalsBaseline: initialRawTotalsBaseline, + hasDivergentTotals: initialHasDivergentTotals, + lastCodexTurnID: initialCodexTurnID, + sessionId: nil, + forkedFromId: nil, + rows: []) + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + static func parseCodexFileCancellable( + fileURL: URL, + range: CostUsageDayRange, + startOffset: Int64 = 0, + initialModel: String? = nil, + initialTotals: CostUsageCodexTotals? = nil, + initialRawTotalsBaseline: CostUsageCodexTotals? = nil, + initialHasDivergentTotals: Bool = false, + initialCodexTurnID: String? = nil, + inheritedTotalsResolver: ((String, String) throws -> CodexForkBaseline)? = nil, + checkCancellation: CancellationCheck? = nil) throws -> CodexParseResult { var currentModel = initialModel var previousTotals = initialTotals @@ -1072,9 +1218,13 @@ enum CostUsageScanner { var forkedFromId: String? var inheritedTotals: CostUsageCodexTotals? var remainingInheritedTotals: CostUsageCodexTotals? + var forkBaselineResolved = false + var hasUnresolvedForkBaseline = false + var unresolvedForkTotalWatermark: CostUsageCodexTotals? var currentTurnID = initialCodexTurnID var rawTotalsBaseline = initialRawTotalsBaseline ?? initialTotals var sawDivergentTotals = initialHasDivergentTotals + var deferredError: Error? var days: [String: [String: [Int]]] = [:] var rows: [CodexUsageRow] = [] @@ -1093,6 +1243,20 @@ enum CostUsageScanner { days[dayKey] = dayModels } + func resolveForkBaseline(parentSessionId: String, forkedAt: String) throws { + guard !forkBaselineResolved else { return } + guard let inheritedTotalsResolver else { return } + forkBaselineResolved = true + switch try inheritedTotalsResolver(parentSessionId, forkedAt) { + case let .resolved(totals): + inheritedTotals = totals + remainingInheritedTotals = totals + hasUnresolvedForkBaseline = false + case .unresolved: + hasUnresolvedForkBaseline = true + } + } + let maxLineBytes = 256 * 1024 // Bumped from 32KB to maxLineBytes in 0.23.3: Codex CLI 0.125+ emits // turn_context lines ~38–41KB (bundled user_instructions / project @@ -1104,7 +1268,9 @@ enum CostUsageScanner { let prefixBytes = maxLineBytes if startOffset == 0, - let metadata = Self.parseCodexSessionMetadata(fileURL: fileURL) + let metadata = try Self.parseCodexSessionMetadata( + fileURL: fileURL, + checkCancellation: checkCancellation) { sessionId = metadata.sessionId forkedFromId = metadata.forkedFromId @@ -1112,19 +1278,20 @@ enum CostUsageScanner { inheritedTotals == nil { let forkedAt = metadata.forkTimestamp ?? "" - inheritedTotals = inheritedTotalsResolver?(forkedFromId, forkedAt) - remainingInheritedTotals = inheritedTotals + try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: forkedAt) } } - let parsedBytes: Int64 + var parsedBytes: Int64 do { parsedBytes = try CostUsageJsonl.scan( fileURL: fileURL, offset: startOffset, maxLineBytes: maxLineBytes, prefixBytes: prefixBytes, + checkCancellation: checkCancellation, onLine: { line in + if deferredError != nil { return } guard !line.bytes.isEmpty else { return } if line.wasTruncated { // `turn_context` can carry very large prompts, but its model usually appears near the start. @@ -1164,17 +1331,18 @@ enum CostUsageScanner { ?? obj["id"] as? String } if forkedFromId == nil { - forkedFromId = payload?["forked_from_id"] as? String - ?? payload?["forkedFromId"] as? String - ?? payload?["parent_session_id"] as? String - ?? payload?["parentSessionId"] as? String + forkedFromId = Self.codexForkParentId(from: payload) } - if inheritedTotals == nil, let forkedFromId { + if let forkedFromId { let forkedAt = payload?["timestamp"] as? String ?? obj["timestamp"] as? String ?? "" - inheritedTotals = inheritedTotalsResolver?(forkedFromId, forkedAt) - remainingInheritedTotals = inheritedTotals + do { + try resolveForkBaseline(parentSessionId: forkedFromId, forkedAt: forkedAt) + } catch { + deferredError = error + return + } } return } @@ -1216,6 +1384,13 @@ enum CostUsageScanner { return 0 } + func tokenTotals(_ usage: [String: Any]) -> CostUsageCodexTotals { + CostUsageCodexTotals( + input: max(0, toInt(usage["input_tokens"])), + cached: max(0, toInt(usage["cached_input_tokens"] ?? usage["cache_read_input_tokens"])), + output: max(0, toInt(usage["output_tokens"]))) + } + let total = (info?["total_token_usage"] as? [String: Any]) let last = (info?["last_token_usage"] as? [String: Any]) @@ -1245,7 +1420,60 @@ enum CostUsageScanner { return adjusted } - if let last { + let handledUnresolvedForkTotal = hasUnresolvedForkBaseline && total != nil + if hasUnresolvedForkBaseline, let total { + let currentRawTotals = tokenTotals(total) + defer { + unresolvedForkTotalWatermark = currentRawTotals + } + guard let last, + let watermark = unresolvedForkTotalWatermark + else { + return + } + + let rawLastDelta = tokenTotals(last) + let rawTotalDelta = Self.codexTotalDelta(from: watermark, to: currentRawTotals) + let adjustedDelta = Self.codexMinTotals(rawLastDelta, rawTotalDelta) + deltaInput = adjustedDelta.input + deltaCached = adjustedDelta.cached + deltaOutput = adjustedDelta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, adjustedDelta) + rawTotalsBaseline = previousTotals + } + + if !handledUnresolvedForkTotal, + let total, + forkedFromId != nil, + !hasUnresolvedForkBaseline + { + let rawTotals = tokenTotals(total) + let currentTotals: CostUsageCodexTotals = if let inheritedTotals { + CostUsageCodexTotals( + input: max(0, rawTotals.input - inheritedTotals.input), + cached: max(0, rawTotals.cached - inheritedTotals.cached), + output: max(0, rawTotals.output - inheritedTotals.output)) + } else { + rawTotals + } + let delta = sawDivergentTotals + ? Self.codexDivergentTotalDelta( + rawBaseline: rawTotalsBaseline, + countedBaseline: previousTotals, + current: currentTotals) + : Self.codexTotalDelta(from: rawTotalsBaseline, to: currentTotals) + deltaInput = delta.input + deltaCached = delta.cached + deltaOutput = delta.output + let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) + previousTotals = Self.codexAddTotals(prev, delta) + rawTotalsBaseline = currentTotals + if !Self.codexTotalsEqual(rawTotalsBaseline, previousTotals) { + sawDivergentTotals = true + } + remainingInheritedTotals = nil + } else if !handledUnresolvedForkTotal, let last { let rawDelta = CostUsageCodexTotals( input: max(0, toInt(last["input_tokens"])), cached: max(0, toInt(last["cached_input_tokens"] ?? last["cache_read_input_tokens"])), @@ -1257,11 +1485,8 @@ enum CostUsageScanner { deltaOutput = adjustedDelta.output let prev = previousTotals ?? .init(input: 0, cached: 0, output: 0) - if let total { - let rawTotals = CostUsageCodexTotals( - input: toInt(total["input_tokens"]), - cached: toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]), - output: toInt(total["output_tokens"])) + if let total, !hasUnresolvedForkBaseline { + let rawTotals = tokenTotals(total) let currentTotals: CostUsageCodexTotals = if let inheritedTotals { CostUsageCodexTotals( input: max(0, rawTotals.input - inheritedTotals.input), @@ -1296,11 +1521,8 @@ enum CostUsageScanner { previousTotals = countedTotals rawTotalsBaseline = countedTotals } - } else if let total { - let rawTotals = CostUsageCodexTotals( - input: toInt(total["input_tokens"]), - cached: toInt(total["cached_input_tokens"] ?? total["cache_read_input_tokens"]), - output: toInt(total["output_tokens"])) + } else if !handledUnresolvedForkTotal, let total { + let rawTotals = tokenTotals(total) let currentTotals: CostUsageCodexTotals = if let inheritedTotals { CostUsageCodexTotals( @@ -1327,7 +1549,7 @@ enum CostUsageScanner { sawDivergentTotals = true } remainingInheritedTotals = nil - } else { + } else if !handledUnresolvedForkTotal { return } @@ -1355,6 +1577,11 @@ enum CostUsageScanner { } } }) + if let deferredError { + throw deferredError + } + } catch is CancellationError { + throw CancellationError() } catch { self.log.warning( "Codex cost usage failed while scanning session file", @@ -1392,8 +1619,9 @@ enum CostUsageScanner { fileURL: URL, context: CodexFileScanContext, cache: inout CostUsageCache, - state: inout CodexScanState) + state: inout CodexScanState) throws { + try context.checkCancellation?() let metadata = Self.codexFileMetadata(fileURL: fileURL) if let fileId = metadata.fileId, state.seenFileIds.contains(fileId) { Self.dropCachedCodexFile(path: metadata.path, cached: cache.files[metadata.path], cache: &cache) @@ -1410,10 +1638,10 @@ enum CostUsageScanner { if Self.keepCachedCodexFileIfFresh(input: input, context: context, cache: &cache, state: &state) { return } - if Self.appendCodexFileIncrementIfPossible(input: input, context: context, cache: &cache, state: &state) { + if try Self.appendCodexFileIncrementIfPossible(input: input, context: context, cache: &cache, state: &state) { return } - Self.rescanCodexFile(input: input, context: context, cache: &cache, state: &state) + try Self.rescanCodexFile(input: input, context: context, cache: &cache, state: &state) } private static func makeCodexRefreshPlan( @@ -1507,12 +1735,18 @@ enum CostUsageScanner { shouldRefresh: shouldRefresh) } - private static func loadCodexDaily(range: CostUsageDayRange, now: Date, options: Options) -> CostUsageDailyReport { + private static func loadCodexDaily( + range: CostUsageDayRange, + now: Date, + options: Options, + checkCancellation: CancellationCheck?) throws -> CostUsageDailyReport + { var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: options.cacheRoot) let nowMs = Int64(now.timeIntervalSince1970 * 1000) let plan = Self.makeCodexRefreshPlan(cache: cache, range: range, now: now, nowMs: nowMs, options: options) if plan.shouldRefresh { + try checkCancellation?() if options.forceRescan { cache = CostUsageCache() } @@ -1564,8 +1798,11 @@ enum CostUsageScanner { let fileIndex = CodexSessionFileIndex( files: files, roots: plan.roots, - cachedSessionFiles: Self.cachedCodexSessionIndex(cache: cache, roots: plan.roots)) - let inheritedResolver = CodexInheritedTotalsResolver(fileIndex: fileIndex) + cachedSessionFiles: Self.cachedCodexSessionIndex(cache: cache, roots: plan.roots), + checkCancellation: checkCancellation) + let inheritedResolver = CodexInheritedTotalsResolver( + fileIndex: fileIndex, + checkCancellation: checkCancellation) let resources = CodexScanResources( fileIndex: fileIndex, inheritedResolver: inheritedResolver, @@ -1573,7 +1810,7 @@ enum CostUsageScanner { modelsDevCacheRoot: options.cacheRoot, priorityTurns: plan.priorityTurns) for fileURL in files { - Self.scanCodexFile( + try Self.scanCodexFile( fileURL: fileURL, context: CodexFileScanContext( range: range, @@ -1584,10 +1821,12 @@ enum CostUsageScanner { || plan.needsTurnIDCacheMigration, requiresTurnIDCache: plan.needsTurnIDCacheMigration, changedPriorityTurnIDs: plan.changedPriorityTurnIDs, - resources: resources), + resources: resources, + checkCancellation: checkCancellation), cache: &cache, state: &scanState) } + try checkCancellation?() Self.pruneForceRescanFilesOutsideWindow( cache: &cache, @@ -1646,6 +1885,7 @@ enum CostUsageScanner { retainedUntilKey: retainedUntilKey) } cache.lastScanUnixMs = nowMs + try checkCancellation?() CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: options.cacheRoot) } diff --git a/Sources/CodexBarCore/WidgetSnapshot.swift b/Sources/CodexBarCore/WidgetSnapshot.swift index d87c4dcc..17affa8b 100644 --- a/Sources/CodexBarCore/WidgetSnapshot.swift +++ b/Sources/CodexBarCore/WidgetSnapshot.swift @@ -55,17 +55,54 @@ public struct WidgetSnapshot: Codable, Sendable { public let sessionTokens: Int? public let last30DaysCostUSD: Double? public let last30DaysTokens: Int? + public let currencyCode: String + public let sessionLabel: String + public let last30DaysLabel: String public init( sessionCostUSD: Double?, sessionTokens: Int?, last30DaysCostUSD: Double?, - last30DaysTokens: Int?) + last30DaysTokens: Int?, + currencyCode: String = "USD", + sessionLabel: String = "Today", + last30DaysLabel: String = "30d") { self.sessionCostUSD = sessionCostUSD self.sessionTokens = sessionTokens self.last30DaysCostUSD = last30DaysCostUSD self.last30DaysTokens = last30DaysTokens + self.currencyCode = currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "USD" + : currencyCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + self.sessionLabel = sessionLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "Today" + : sessionLabel + self.last30DaysLabel = last30DaysLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? "30d" + : last30DaysLabel + } + + private enum CodingKeys: String, CodingKey { + case sessionCostUSD + case sessionTokens + case last30DaysCostUSD + case last30DaysTokens + case currencyCode + case sessionLabel + case last30DaysLabel + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + try self.init( + sessionCostUSD: container.decodeIfPresent(Double.self, forKey: .sessionCostUSD), + sessionTokens: container.decodeIfPresent(Int.self, forKey: .sessionTokens), + last30DaysCostUSD: container.decodeIfPresent(Double.self, forKey: .last30DaysCostUSD), + last30DaysTokens: container.decodeIfPresent(Int.self, forKey: .last30DaysTokens), + currencyCode: container.decodeIfPresent(String.self, forKey: .currencyCode) ?? "USD", + sessionLabel: container.decodeIfPresent(String.self, forKey: .sessionLabel) ?? "Today", + last30DaysLabel: container.decodeIfPresent(String.self, forKey: .last30DaysLabel) ?? "30d") } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index 17699f9e..68fd9722 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -184,13 +184,19 @@ private struct CompactMetricView: View { let value = self.entry.creditsRemaining.map(WidgetFormat.credits) ?? "—" return (value, "Credits left", nil) case .todayCost: - let value = self.entry.tokenUsage?.sessionCostUSD.map(WidgetFormat.usd) ?? "—" + let value = self.entry.tokenUsage.map { token in + token.sessionCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" + } ?? "—" let detail = self.entry.tokenUsage?.sessionTokens.map(WidgetFormat.tokenCount) - return (value, "Today cost", detail) + let label = self.entry.tokenUsage.map { "\($0.sessionLabel) cost" } ?? "Today cost" + return (value, label, detail) case .last30DaysCost: - let value = self.entry.tokenUsage?.last30DaysCostUSD.map(WidgetFormat.usd) ?? "—" + let value = self.entry.tokenUsage.map { token in + token.last30DaysCostUSD.map { WidgetFormat.currency($0, code: token.currencyCode) } ?? "—" + } ?? "—" let detail = self.entry.tokenUsage?.last30DaysTokens.map(WidgetFormat.tokenCount) - return (value, "30d cost", detail) + let label = self.entry.tokenUsage.map { "\($0.last30DaysLabel) cost" } ?? "30d cost" + return (value, label, detail) } } } @@ -346,8 +352,11 @@ private struct SwitcherMediumUsageView: View { } if let token = entry.tokenUsage { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) } } } @@ -376,13 +385,17 @@ private struct SwitcherLargeUsageView: View { if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( - title: "30d", + title: token.last30DaysLabel, value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, - tokens: token.last30DaysTokens)) + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) @@ -431,8 +444,11 @@ private struct MediumUsageView: View { } if let token = entry.tokenUsage { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) } } .padding(12) @@ -463,13 +479,17 @@ private struct LargeUsageView: View { if let token = entry.tokenUsage { VStack(alignment: .leading, spacing: 4) { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( - title: "30d", + title: token.last30DaysLabel, value: WidgetFormat.costAndTokens( cost: token.last30DaysCostUSD, - tokens: token.last30DaysTokens)) + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } UsageHistoryChart(points: self.entry.dailyUsage, color: WidgetColors.color(for: self.entry.provider)) @@ -492,7 +512,7 @@ struct WidgetUsageRow: Identifiable, Equatable { } let metadata = ProviderDefaults.metadata[entry.provider] - return [ + var rows = [ WidgetUsageRow( id: "primary", title: metadata?.sessionLabel ?? "Session", @@ -501,7 +521,14 @@ struct WidgetUsageRow: Identifiable, Equatable { id: "secondary", title: metadata?.weeklyLabel ?? "Weekly", percentLeft: entry.secondary?.remainingPercent), - ].filter { $0.percentLeft != nil } + ] + if metadata?.supportsOpus == true { + rows.append(WidgetUsageRow( + id: "tertiary", + title: metadata?.opusLabel ?? "Opus", + percentLeft: entry.tertiary?.remainingPercent)) + } + return rows.filter { $0.percentLeft != nil } } } @@ -516,11 +543,17 @@ private struct HistoryView: View { .frame(height: self.isLarge ? 90 : 60) if let token = entry.tokenUsage { ValueLine( - title: "Today", - value: WidgetFormat.costAndTokens(cost: token.sessionCostUSD, tokens: token.sessionTokens)) + title: token.sessionLabel, + value: WidgetFormat.costAndTokens( + cost: token.sessionCostUSD, + tokens: token.sessionTokens, + currencyCode: token.currencyCode)) ValueLine( - title: "30d", - value: WidgetFormat.costAndTokens(cost: token.last30DaysCostUSD, tokens: token.last30DaysTokens)) + title: token.last30DaysLabel, + value: WidgetFormat.costAndTokens( + cost: token.last30DaysCostUSD, + tokens: token.last30DaysTokens, + currencyCode: token.currencyCode)) } } .padding(12) @@ -726,21 +759,21 @@ enum WidgetFormat { return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value) } - static func costAndTokens(cost: Double?, tokens: Int?) -> String { - let costText = cost.map(self.usd) ?? "—" + static func costAndTokens(cost: Double?, tokens: Int?, currencyCode: String = "USD") -> String { + let costText = cost.map { self.currency($0, code: currencyCode) } ?? "—" if let tokens { return "\(costText) · \(self.tokenCount(tokens))" } return costText } - static func usd(_ value: Double) -> String { + static func currency(_ value: Double, code: String) -> String { let formatter = NumberFormatter() formatter.numberStyle = .currency - formatter.currencyCode = "USD" + formatter.currencyCode = code formatter.maximumFractionDigits = 2 formatter.minimumFractionDigits = 2 - return formatter.string(from: NSNumber(value: value)) ?? String(format: "$%.2f", value) + return formatter.string(from: NSNumber(value: value)) ?? "\(code) \(String(format: "%.2f", value))" } static func tokenCount(_ value: Int) -> String { diff --git a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift index 65922a23..4916dd0e 100644 --- a/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift +++ b/Tests/CodexBarTests/AlibabaTokenPlanProviderTests.swift @@ -52,11 +52,11 @@ struct AlibabaTokenPlanSettingsReaderTests { } @Test - func `default quota URL targets token plan API`() { + func `default quota URL targets subscription summary API`() { let url = AlibabaTokenPlanUsageFetcher.defaultQuotaURL - #expect(url.host == "bailian-cs.console.aliyun.com") - #expect(url.absoluteString.contains("queryTokenPlanInstanceInfo")) - #expect(url.absoluteString.contains("BroadScopeAspnGateway")) + #expect(url.host == "bailian.console.aliyun.com") + #expect(url.absoluteString.contains("GetSubscriptionSummary")) + #expect(url.absoluteString.contains("BssOpenAPI-V3")) } } @@ -68,17 +68,17 @@ struct AlibabaTokenPlanCookieHeaderTests { self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"), self.cookie(name: "sec_token", value: "shared", domain: ".console.aliyun.com"), self.cookie(name: "sec_token", value: "dashboard", domain: "bailian.console.aliyun.com"), - self.cookie(name: "sec_token", value: "api", domain: "bailian-cs.console.aliyun.com"), + self.cookie(name: "modelstudio_only", value: "modelstudio", domain: "modelstudio.console.alibabacloud.com"), ] let headers = try #require(AlibabaTokenPlanCookieHeader.headers(from: cookies)) #expect(headers.apiCookieHeader.contains("login_aliyunid_ticket=ticket")) #expect(headers.apiCookieHeader.contains("login_current_pk=account")) - #expect(headers.apiCookieHeader.contains("sec_token=api")) - #expect(!headers.apiCookieHeader.contains("sec_token=dashboard")) + #expect(headers.apiCookieHeader.contains("sec_token=dashboard")) + #expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio")) #expect(headers.dashboardCookieHeader.contains("sec_token=dashboard")) - #expect(!headers.dashboardCookieHeader.contains("sec_token=api")) + #expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio")) } @Test @@ -101,7 +101,7 @@ struct AlibabaTokenPlanCookieHeaderTests { self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"), self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"), self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"), - self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian-cs.console.aliyun.com"), + self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian.console.aliyun.com"), self.cookie(name: "prod_dashboard_only", value: "prod-dashboard", domain: "bailian.console.aliyun.com"), ] @@ -178,23 +178,18 @@ struct AlibabaTokenPlanUsageSnapshotTests { @Suite(.serialized) struct AlibabaTokenPlanUsageParsingTests { @Test - func `parses token plan payload`() throws { + func `parses subscription summary payload`() throws { let now = Date(timeIntervalSince1970: 1_700_000_000) let json = """ { - "data": { - "tokenPlanInstanceInfo": { - "planName": "TOKEN PLAN", - "status": "VALID", - "quotaInfo": { - "usedQuota": 125, - "totalQuota": 1000, - "remainingQuota": 875 - }, - "periodEndTime": 1701000000000 - } + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 1000, + "TotalSurplusValue": 875, + "NearestExpireDate": 1701000000000 }, - "status_code": 0 + "Code": "200" } """ @@ -209,19 +204,15 @@ struct AlibabaTokenPlanUsageParsingTests { } @Test - func `parses remaining and total quota`() throws { + func `parses nested subscription summary body`() throws { let body = """ { + "success": true, "data": { - "tokenPlanInstanceInfo": { - "packageName": "TOKEN PLAN", - "quotaInfo": { - "remainingCredits": 750, - "totalCredits": 1000 - } - } - }, - "statusCode": 200 + "totalCount": 1, + "totalSurplusValue": 750, + "totalValue": 1000 + } } """ let payload = ["successResponse": ["body": body]] @@ -230,29 +221,26 @@ struct AlibabaTokenPlanUsageParsingTests { let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: data) #expect(snapshot.planName == "TOKEN PLAN") - #expect(snapshot.usedQuota == nil) + #expect(snapshot.usedQuota == 250) #expect(snapshot.remainingQuota == 750) #expect(snapshot.totalQuota == 1000) #expect(snapshot.toUsageSnapshot().primary?.usedPercent == 25) } @Test - func `plan only payload stays visible without quota window`() throws { + func `empty subscription summary stays visible without quota window`() throws { let json = """ { - "data": { - "tokenPlanInstanceInfo": { - "planName": "TOKEN PLAN", - "status": "VALID" - } - }, - "status_code": 0 + "Success": true, + "Data": { + "TotalCount": 0 + } } """ let snapshot = try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: Data(json.utf8)) - #expect(snapshot.planName == "TOKEN PLAN") + #expect(snapshot.planName == nil) #expect(snapshot.totalQuota == nil) #expect(snapshot.toUsageSnapshot().primary == nil) } @@ -272,6 +260,22 @@ struct AlibabaTokenPlanUsageParsingTests { } } + @Test + func `nested unsuccessful subscription summary maps to API error`() throws { + let body = """ + { + "success": false, + "message": "Subscription lookup failed" + } + """ + let payload = ["successResponse": ["body": body]] + let data = try JSONSerialization.data(withJSONObject: payload) + + #expect(throws: AlibabaTokenPlanUsageError.apiError("Subscription lookup failed")) { + try AlibabaTokenPlanUsageFetcher.parseUsageSnapshot(from: data) + } + } + @Test func `forbidden payload maps to invalid credentials`() { let json = """ @@ -326,17 +330,18 @@ struct AlibabaTokenPlanUsageParsingTests { .absoluteString) let body = Self.requestBodyString(from: request) #expect(!body.contains("sec_token=")) - #expect(body.contains("commodityCode")) + #expect(body.contains("GetSubscriptionSummary")) + #expect(body.contains("BssOpenAPI-V3")) + #expect(body.contains("ProductCode")) #expect(body.contains("sfm_tokenplanteams_dp_cn")) - #expect(body.contains("onlyLatestOne")) let json = """ { - "data": { - "tokenPlanInstanceInfo": { - "planName": "TOKEN PLAN" - } - }, - "status_code": 0 + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 1000, + "TotalSurplusValue": 900 + } } """ return Self.makeResponse(url: url, body: json, statusCode: 200) @@ -374,12 +379,12 @@ struct AlibabaTokenPlanUsageParsingTests { #expect(body.contains("sec_token=session-html-token")) let json = """ { - "data": { - "tokenPlanInstanceInfo": { - "planName": "TOKEN PLAN" - } - }, - "status_code": 0 + "Success": true, + "Data": { + "TotalCount": 1, + "TotalValue": 1000, + "TotalSurplusValue": 900 + } } """ return Self.makeResponse(url: url, body: json, statusCode: 200) @@ -405,10 +410,10 @@ struct AlibabaTokenPlanUsageParsingTests { @Test func `redirect preserves cookie only for same host HTTPS requests`() throws { - let sourceURL = try #require(URL(string: "https://bailian-cs.console.aliyun.com/data/api.json")) - let sameHostURL = try #require(URL(string: "https://bailian-cs.console.aliyun.com/redirected")) + let sourceURL = try #require(URL(string: "https://bailian.console.aliyun.com/data/api.json")) + let sameHostURL = try #require(URL(string: "https://bailian.console.aliyun.com/redirected")) let crossHostURL = try #require(URL(string: "https://signin.aliyun.com/login")) - let insecureURL = try #require(URL(string: "http://bailian-cs.console.aliyun.com/redirected")) + let insecureURL = try #require(URL(string: "http://bailian.console.aliyun.com/redirected")) let response = try #require(HTTPURLResponse( url: sourceURL, statusCode: 302, @@ -553,7 +558,7 @@ struct AlibabaTokenPlanWebStrategyTests { } @Test - func `auto web strategy imports URL scoped token plan cookies`() throws { + func `auto web strategy imports subscription scoped token plan cookies`() throws { let strategy = AlibabaTokenPlanWebFetchStrategy() let settings = ProviderSettingsSnapshot.make( alibabaTokenPlan: ProviderSettingsSnapshot.AlibabaTokenPlanProviderSettings( @@ -579,7 +584,10 @@ struct AlibabaTokenPlanWebStrategyTests { self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".aliyun.com"), self.cookie(name: "login_current_pk", value: "account", domain: ".aliyun.com"), self.cookie(name: "dashboard_only", value: "dashboard", domain: "bailian.console.aliyun.com"), - self.cookie(name: "api_only", value: "api", domain: "bailian-cs.console.aliyun.com"), + self.cookie( + name: "modelstudio_only", + value: "modelstudio", + domain: "modelstudio.console.alibabacloud.com"), self.cookie(name: "alibabacloud_only", value: "cloud", domain: ".alibabacloud.com"), ], sourceLabel: "Chrome Default") @@ -591,12 +599,12 @@ struct AlibabaTokenPlanWebStrategyTests { let headers = try AlibabaTokenPlanWebFetchStrategy.resolveCookieHeaders(context: context, allowCached: false) - #expect(headers.apiCookieHeader != headers.dashboardCookieHeader) - #expect(!headers.apiCookieHeader.contains("dashboard_only=dashboard")) - #expect(headers.apiCookieHeader.contains("api_only=api")) + #expect(headers.apiCookieHeader == headers.dashboardCookieHeader) + #expect(headers.apiCookieHeader.contains("dashboard_only=dashboard")) + #expect(!headers.apiCookieHeader.contains("modelstudio_only=modelstudio")) #expect(!headers.apiCookieHeader.contains("alibabacloud_only=cloud")) #expect(headers.dashboardCookieHeader.contains("dashboard_only=dashboard")) - #expect(!headers.dashboardCookieHeader.contains("api_only=api")) + #expect(!headers.dashboardCookieHeader.contains("modelstudio_only=modelstudio")) #expect(!headers.dashboardCookieHeader.contains("alibabacloud_only=cloud")) AlibabaCodingPlanCookieImporter.importSessionOverrideForTesting = { _, _ in @@ -640,7 +648,7 @@ struct AlibabaTokenPlanWebStrategyTests { self.cookie(name: "login_aliyunid_ticket", value: "ticket", domain: ".token-plan.test"), self.cookie(name: "api_only", value: "api", domain: "quota.token-plan.test"), self.cookie(name: "dashboard_only", value: "dashboard", domain: "dashboard.token-plan.test"), - self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian-cs.console.aliyun.com"), + self.cookie(name: "prod_api_only", value: "prod-api", domain: "bailian.console.aliyun.com"), self.cookie( name: "prod_dashboard_only", value: "prod-dashboard", @@ -685,7 +693,6 @@ final class AlibabaTokenPlanStubURLProtocol: URLProtocol { override static func canInit(with request: URLRequest) -> Bool { guard let host = request.url?.host else { return false } return host == "bailian.console.aliyun.com" || - host == "bailian-cs.console.aliyun.com" || host == "alibaba-token-plan.test" || host == "session-token.test" } diff --git a/Tests/CodexBarTests/AmpUsageFetcherTests.swift b/Tests/CodexBarTests/AmpUsageFetcherTests.swift index 135f58ee..25963d8d 100644 --- a/Tests/CodexBarTests/AmpUsageFetcherTests.swift +++ b/Tests/CodexBarTests/AmpUsageFetcherTests.swift @@ -17,11 +17,22 @@ struct AmpUsageFetcherTests { #expect(!AmpUsageFetcher.shouldAttachCookie(to: nil)) } + @Test + func `rejects non https amp urls`() { + #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://ampcode.com/settings"))) + #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://www.ampcode.com"))) + #expect(!AmpUsageFetcher.shouldAttachCookie(to: URL(string: "http://app.ampcode.com/path"))) + } + @Test func `detects login redirects`() throws { let signIn = try #require(URL(string: "https://ampcode.com/auth/sign-in?returnTo=%2Fsettings")) #expect(AmpUsageFetcher.isLoginRedirect(signIn)) + let downgradedSignIn = try #require(URL(string: "http://ampcode.com/auth/sign-in?returnTo=%2Fsettings")) + #expect(AmpUsageFetcher.isLoginRedirect(downgradedSignIn)) + #expect(!AmpUsageFetcher.shouldAttachCookie(to: downgradedSignIn)) + let sso = try #require(URL(string: "https://ampcode.com/auth/sso?returnTo=%2Fsettings")) #expect(AmpUsageFetcher.isLoginRedirect(sso)) diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index 6a1b8cb2..fe35ba60 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -801,6 +801,62 @@ struct AntigravityStatusProbeTests { #expect(usage.tertiary?.remainingPercent.rounded() == 100) #expect(usage.identity?.accountEmail == "user@example.com") } +} + +extension AntigravityStatusProbeTests { + @Test + func `extra rate windows preserve all model quotas in stable family order`() throws { + let resetTime = Date(timeIntervalSince1970: 1_775_000_000) + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "GPT-OSS 120B (Medium)", + modelId: "MODEL_PLACEHOLDER_M55", + remainingFraction: 0.25, + resetTime: resetTime, + resetDescription: "tomorrow"), + AntigravityModelQuota( + label: "Gemini 3 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M53", + remainingFraction: 0.5, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Opus 4.6 (Thinking)", + modelId: "MODEL_PLACEHOLDER_M50", + remainingFraction: 0.75, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "MODEL_PLACEHOLDER_M52", + remainingFraction: 1, + resetTime: resetTime, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + + // Local source shows all models. Order: Claude → Gemini Pro (High before Low) → GPT-OSS (unknown, last) + #expect(extraWindows.map(\.id) == [ + "MODEL_PLACEHOLDER_M50", + "MODEL_PLACEHOLDER_M52", + "MODEL_PLACEHOLDER_M53", + "MODEL_PLACEHOLDER_M55", + ]) + #expect(extraWindows.map(\.title) == [ + "Claude Opus 4.6 (Thinking)", + "Gemini 3 Pro (High)", + "Gemini 3 Pro (Low)", + "GPT-OSS 120B (Medium)", + ]) + #expect(extraWindows.map { $0.window.remainingPercent.rounded() } == [75, 100, 50, 25]) + #expect(extraWindows.last?.window.resetDescription == "tomorrow") + } @Test func `model without remaining fraction keeps reset time`() throws { @@ -853,7 +909,8 @@ struct AntigravityStatusProbeTests { resetDescription: nil), ], accountEmail: "test@example.com", - accountPlan: "Pro") + accountPlan: "Pro", + source: .local) let usage = try snapshot.toUsageSnapshot() #expect(usage.primary?.remainingPercent.rounded() == 20) @@ -863,6 +920,481 @@ struct AntigravityStatusProbeTests { #expect(usage.loginMethod(for: .antigravity) == "Pro") } + // MARK: - Source-aware filter + sort tests + + @Test + func `local source shows all models including gpt oss at full remaining fraction`() throws { + // Fixture A: 8 opaque-ID models, source .local → all shown (show-all path) + let resetTime = Date(timeIntervalSince1970: 1_775_000_000) + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Sonnet 4.6 (Thinking)", + modelId: "MODEL_PLACEHOLDER_M60", + remainingFraction: 0.8, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Opus 4.6 (Thinking)", + modelId: "MODEL_PLACEHOLDER_M61", + remainingFraction: 0.7, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.1 Pro (High)", + modelId: "MODEL_PLACEHOLDER_M62", + remainingFraction: 0.9, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.1 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M63", + remainingFraction: 0.4, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.5 Flash (High)", + modelId: "MODEL_PLACEHOLDER_M64", + remainingFraction: 0.6, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.5 Flash (Low)", + modelId: "MODEL_PLACEHOLDER_M65", + remainingFraction: 0.3, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.5 Flash (Medium)", + modelId: "MODEL_PLACEHOLDER_M66", + remainingFraction: 0.5, + resetTime: resetTime, + resetDescription: nil), + // GPT-OSS pinned at remainingFraction == 1.0 — shown by local show-all + AntigravityModelQuota( + label: "GPT-OSS 120B (Medium)", + modelId: "MODEL_PLACEHOLDER_M55", + remainingFraction: 1.0, + resetTime: resetTime, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let ids = extraWindows.map(\.id) + + // All 8 models present + #expect(ids.count == 8) + // GPT-OSS shown despite remainingFraction == 1.0 (local show-all regression guard) + #expect(ids.contains("MODEL_PLACEHOLDER_M55")) + + // Order: Claude (version 4.6 → both at same version, Opus vs Sonnet by label) + // → Gemini Pro 3.1 (High before Low) + // → Gemini Flash 3.5 (High, Medium, Low by tier) + // → GPT-OSS (unknown bucket, last) + let titles = extraWindows.map(\.title) + // Claude family first + let claudeRange = titles.prefix(2) + #expect(claudeRange.allSatisfy { $0.lowercased().contains("claude") }) + // Gemini Pro next + let geminiProRange = titles.dropFirst(2).prefix(2) + #expect(geminiProRange.allSatisfy { $0.lowercased().contains("gemini") && $0.lowercased().contains("pro") }) + // Gemini Flash next + let geminiFlashRange = titles.dropFirst(4).prefix(3) + #expect(geminiFlashRange.allSatisfy { $0.lowercased().contains("gemini") && $0.lowercased().contains("flash") }) + // GPT-OSS last + #expect(titles.last == "GPT-OSS 120B (Medium)") + + // Within Gemini Pro 3.1: High before Low + let proTitles = Array(geminiProRange) + #expect(proTitles[0].contains("High")) + #expect(proTitles[1].contains("Low")) + + // Within Gemini Flash 3.5: High(0) → Medium(1) → Low(2) + let flashTitles = Array(geminiFlashRange) + #expect(flashTitles[0].contains("High")) + #expect(flashTitles[1].contains("Medium")) + #expect(flashTitles[2].contains("Low")) + } + + @Test + func `remote source filters junk models and keeps family recognized ones`() throws { + // Fixture B: verified 13 remote models; 6 junk hidden, 7 survivors present + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + // junk: image + AntigravityModelQuota( + label: "Gemini 2.5 Flash Image", + modelId: "gemini-2-5-flash-image", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: tab autocomplete + AntigravityModelQuota( + label: "Tab Flash Lite Vertex", + modelId: "tab_flash_lite_vertex", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 2.5 Pro", + modelId: "gemini-2-5-pro", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "gemini-3-pro-high", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: lite + AntigravityModelQuota( + label: "Gemini 2.5 Flash Lite", + modelId: "gemini-2-5-flash-lite", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: image + AntigravityModelQuota( + label: "Gemini 3 Pro Image", + modelId: "gemini-3-pro-image", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "gemini-3-flash", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: lite + AntigravityModelQuota( + label: "Gemini 3.1 Flash Lite", + modelId: "gemini-3-1-flash-lite", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3.1 Pro (Low)", + modelId: "gemini-3-1-pro-low", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3.1 Pro (High)", + modelId: "gemini-3-1-pro-high", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // junk: tab autocomplete + AntigravityModelQuota( + label: "Tab Jump Flash Lite Vertex", + modelId: "tab_jump_flash_lite_vertex", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 3 Pro (Low)", + modelId: "gemini-3-pro-low", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + // survivor + AntigravityModelQuota( + label: "Gemini 2.5 Flash", + modelId: "gemini-2-5-flash", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let ids = extraWindows.map(\.id) + + // 6 junk IDs must be absent + #expect(!ids.contains("gemini-2-5-flash-image")) + #expect(!ids.contains("tab_flash_lite_vertex")) + #expect(!ids.contains("gemini-2-5-flash-lite")) + #expect(!ids.contains("gemini-3-pro-image")) + #expect(!ids.contains("gemini-3-1-flash-lite")) + #expect(!ids.contains("tab_jump_flash_lite_vertex")) + + // 7 survivors must be present by ID + #expect(ids.contains("gemini-2-5-pro")) + #expect(ids.contains("gemini-3-pro-high")) + #expect(ids.contains("gemini-3-flash")) + #expect(ids.contains("gemini-3-1-pro-low")) + #expect(ids.contains("gemini-3-1-pro-high")) + #expect(ids.contains("gemini-3-pro-low")) + #expect(ids.contains("gemini-2-5-flash")) + + // Version-descending within Gemini Pro: 3.1 before 3 before 2.5 + let proIds = ids.filter { $0.contains("pro") && !$0.contains("image") } + let proIndexOf: (String) -> Int = { id in proIds.firstIndex(of: id) ?? Int.max } + #expect(proIndexOf("gemini-3-1-pro-high") < proIndexOf("gemini-3-pro-high")) + #expect(proIndexOf("gemini-3-pro-high") < proIndexOf("gemini-2-5-pro")) + + // Within same version, High before Low + #expect(proIndexOf("gemini-3-1-pro-high") < proIndexOf("gemini-3-1-pro-low")) + #expect(proIndexOf("gemini-3-pro-high") < proIndexOf("gemini-3-pro-low")) + } + + @Test + func `remote source shows consumed junk models despite filter`() throws { + // Fixture C: junk models with remainingFraction < 0.999 must be shown + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + // consumed tab — should be shown + AntigravityModelQuota( + label: "Tab Flash Lite Vertex", + modelId: "tab_flash_lite_vertex", + remainingFraction: 0.4, + resetTime: nil, + resetDescription: nil), + // consumed image — should be shown + AntigravityModelQuota( + label: "Gemini 3 Pro Image", + modelId: "gemini-3-pro-image", + remainingFraction: 0.4, + resetTime: nil, + resetDescription: nil), + // unconsumed sibling tab (0.9995 >= 0.999) — should be hidden + AntigravityModelQuota( + label: "Tab Jump Flash Lite Vertex", + modelId: "tab_jump_flash_lite_vertex", + remainingFraction: 0.9995, + resetTime: nil, + resetDescription: nil), + // a clean survivor for non-empty guard + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "gemini-3-flash", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let ids = extraWindows.map(\.id) + + // Consumed junk models shown despite being junk type + #expect(ids.contains("tab_flash_lite_vertex")) + #expect(ids.contains("gemini-3-pro-image")) + + // Unconsumed sibling stays hidden + #expect(!ids.contains("tab_jump_flash_lite_vertex")) + } + + @Test + func `remote source image models do not drive family summary bars`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3 Pro Image", + modelId: "gemini-3-pro-image", + remainingFraction: 0.2, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "gemini-3-pro-high", + remainingFraction: 0.9, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Flash Image", + modelId: "gemini-3-flash-image", + remainingFraction: 0.1, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "gemini-3-flash", + remainingFraction: 0.8, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + + #expect(usage.secondary?.usedPercent == 10) + #expect(usage.tertiary?.usedPercent == 20) + #expect(usage.extraRateWindows?.map(\.id).contains("gemini-3-pro-image") == true) + #expect(usage.extraRateWindows?.map(\.id).contains("gemini-3-flash-image") == true) + } + + @Test + func `remote source yields nil extra windows when all models are unconsumed junk`() throws { + // Fixture D: all-junk-unconsumed → extraRateWindows nil + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Tab Flash Lite Vertex", + modelId: "tab_flash_lite_vertex", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 2.5 Flash Lite", + modelId: "gemini-2-5-flash-lite", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro Image", + modelId: "gemini-3-pro-image", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Unknown Model X", + modelId: "unknown-model-x", + remainingFraction: 1.0, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + #expect(usage.extraRateWindows == nil) + } + + @Test + func `ordering edge cases with unparseable version and equal version differing tier`() throws { + // Fixture F: local source; unparseable-version Gemini Pro lands last in pro group; + // same-version High precedes Low + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M70", + remainingFraction: 0.5, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "MODEL_PLACEHOLDER_M71", + remainingFraction: 0.8, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini Pro Experimental", + modelId: "MODEL_PLACEHOLDER_M72", + remainingFraction: 0.3, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Sonnet 4", + modelId: "MODEL_PLACEHOLDER_M73", + remainingFraction: 0.9, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let titles = extraWindows.map(\.title) + + // Claude first + #expect(titles[0] == "Claude Sonnet 4") + // Within Gemini Pro: version-3 models before unparseable-version model + // High before Low at same version + let proIndex: (String) -> Int = { t in titles.firstIndex(of: t) ?? Int.max } + #expect(proIndex("Gemini 3 Pro (High)") < proIndex("Gemini 3 Pro (Low)")) + #expect(proIndex("Gemini 3 Pro (High)") < proIndex("Gemini Pro Experimental")) + #expect(proIndex("Gemini 3 Pro (Low)") < proIndex("Gemini Pro Experimental")) + } + + @Test + func `nil version unknown family models sort deterministically by label`() throws { + // Strict-weak-ordering guard: two .unknown models with unparseable versions + // should sort by label without trapping + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Zebra Unknown Model", + modelId: "MODEL_PLACEHOLDER_MA", + remainingFraction: 0.5, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Alpha Unknown Model", + modelId: "MODEL_PLACEHOLDER_MB", + remainingFraction: 0.5, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .local) + + let usage = try snapshot.toUsageSnapshot() + let extraWindows = try #require(usage.extraRateWindows) + let titles = extraWindows.map(\.title) + + // Deterministic: label tiebreaker → Alpha before Zebra + #expect(titles == ["Alpha Unknown Model", "Zebra Unknown Model"]) + } + + @Test + func `hyphenated raw model ids without display name parse minor version`() throws { + // When the remote catalog omits displayName/label, the raw hyphenated model id + // becomes the label. The newer 3.1 entry must still sort before the 3.0 entry. + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "gemini-3-pro-high", + modelId: "gemini-3-pro-high", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "gemini-3-1-pro-low", + modelId: "gemini-3-1-pro-low", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil, + source: .remote) + + let usage = try snapshot.toUsageSnapshot() + let titles = try #require(usage.extraRateWindows).map(\.title) + + // 3.1 parses from the hyphenated id and sorts newest-first, ahead of 3.0. + #expect(titles == ["gemini-3-1-pro-low", "gemini-3-pro-high"]) + } + @Test func `http probe errors still count as reachable`() { #expect( diff --git a/Tests/CodexBarTests/AppDelegateTests.swift b/Tests/CodexBarTests/AppDelegateTests.swift index 5cee1743..ffc7656a 100644 --- a/Tests/CodexBarTests/AppDelegateTests.swift +++ b/Tests/CodexBarTests/AppDelegateTests.swift @@ -9,6 +9,8 @@ struct AppDelegateTests { func `builds status controller after launch`() { let appDelegate = AppDelegate() var factoryCalls = 0 + var ttyShutdowns = 0 + let dummyStatusController = DummyStatusController() let managedCodexAccountCoordinator = ManagedCodexAccountCoordinator() let settings = SettingsStore( @@ -22,13 +24,16 @@ struct AppDelegateTests { settingsStore: settings, usageStore: store, managedAccountCoordinator: managedCodexAccountCoordinator) + appDelegate.terminateActiveProcessesForAppShutdown = { + ttyShutdowns += 1 + } // Install a test factory that records invocations without touching NSStatusBar. StatusItemController.factory = { _, _, _, _, _, receivedManagedCoordinator, receivedPromotionCoordinator in factoryCalls += 1 #expect(receivedManagedCoordinator === managedCodexAccountCoordinator) #expect(receivedPromotionCoordinator === promotionCoordinator) - return DummyStatusController() + return dummyStatusController } defer { StatusItemController.factory = StatusItemController.defaultFactory } @@ -49,11 +54,21 @@ struct AppDelegateTests { // idempotent on subsequent calls appDelegate.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) #expect(factoryCalls == 1) + + // production termination should ask the status controller to detach AppKit status/menu state + appDelegate.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) + #expect(dummyStatusController.shutdowns == 1) + #expect(ttyShutdowns == 1) } } @MainActor private final class DummyStatusController: StatusItemControlling { + private(set) var shutdowns = 0 + func openMenuFromShortcut() {} func runLoginFlowFromSettings(provider _: UsageProvider) async {} + func prepareForAppShutdown() { + self.shutdowns += 1 + } } diff --git a/Tests/CodexBarTests/AuggieCLIProbeParseTests.swift b/Tests/CodexBarTests/AuggieCLIProbeParseTests.swift new file mode 100644 index 00000000..338d8d2f --- /dev/null +++ b/Tests/CodexBarTests/AuggieCLIProbeParseTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import CodexBarCore + +#if os(macOS) + +struct AuggieCLIProbeParseTests { + private let probe = AuggieCLIProbe() + + @Test + func `parses current auggie account status output`() throws { + let output = """ + ╭ Account ───────────────────────────────────────────────╮ + │ │ + │ 319,054 credits remaining Max Plan │ + │ 450,000 credits / month │ + │ │ + ╰────────────────────────────────────────────────────────╯ + + 9 days remaining in this billing cycle (ends 6/9/2026) + For more detail, visit https://app.augmentcode.com/account + """ + + let snapshot = try probe.parse(output) + + #expect(snapshot.creditsRemaining == 319_054) + #expect(snapshot.creditsLimit == 450_000) + #expect(snapshot.creditsUsed == 130_946) + #expect(snapshot.accountPlan == "\(450_000.formatted()) credits/month") + #expect(snapshot.billingCycleEnd != nil) + } + + @Test + func `parses legacy auggie account status output`() throws { + let output = """ + Max Plan 450,000 credits / month + 11,657 remaining · 953,170 / 964,827 credits used + 2 days remaining in this billing cycle (ends 1/8/2026) + """ + + let snapshot = try probe.parse(output) + + #expect(snapshot.creditsRemaining == 11657) + #expect(snapshot.creditsUsed == 953_170) + #expect(snapshot.creditsLimit == 964_827) + #expect(snapshot.accountPlan == "\(450_000.formatted()) credits/month") + } +} + +#endif diff --git a/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift b/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift index 58d9b0b6..905a8e92 100644 --- a/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift +++ b/Tests/CodexBarTests/AugmentCLIFetchStrategyFallbackTests.swift @@ -79,10 +79,10 @@ struct AugmentCLIFetchStrategyFallbackTests { } @Test - func `parse error does not fall back`() { + func `parse error falls back to web`() { let strategy = AugmentCLIFetchStrategy() let context = self.makeContext() - #expect(strategy.shouldFallback(on: AuggieCLIError.parseError("bad data"), context: context) == false) + #expect(strategy.shouldFallback(on: AuggieCLIError.parseError("bad data"), context: context) == true) } @Test diff --git a/Tests/CodexBarTests/BedrockCredentialResolverTests.swift b/Tests/CodexBarTests/BedrockCredentialResolverTests.swift new file mode 100644 index 00000000..62db841e --- /dev/null +++ b/Tests/CodexBarTests/BedrockCredentialResolverTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing +@testable import CodexBarCore + +private final class CapturedEnvironment: @unchecked Sendable { + private let lock = NSLock() + private var stored: [String: String] = [:] + func record(_ environment: [String: String]) { + self.lock.withLock { self.stored = environment } + } + + var value: [String: String] { + self.lock.withLock { self.stored } + } +} + +@Suite(.serialized) +struct BedrockCredentialResolverTests { + private static let credentialsJSON = #""" + {"Version":1,"AccessKeyId":"AKIAPROFILE","SecretAccessKey":"profile-secret","SessionToken":"profile-token"} + """# + + /// Fake AWS CLI runner: returns exported credentials and a profile region. + private func profileProvider(region: String = "ap-southeast-2") -> BedrockProfileCredentialProvider { + BedrockProfileCredentialProvider(awsBinaryPath: "/usr/bin/aws") { arguments, _ in + if arguments.contains("export-credentials") { + return SubprocessResult(stdout: Self.credentialsJSON, stderr: "") + } + if arguments.contains("get") { + return SubprocessResult(stdout: region + "\n", stderr: "") + } + return SubprocessResult(stdout: "", stderr: "") + } + } + + @Test + func `keys mode resolves static credentials and region`() async throws { + let env = [ + BedrockSettingsReader.accessKeyIDKey: "AKIAKEYS", + BedrockSettingsReader.secretAccessKeyKey: "keys-secret", + BedrockSettingsReader.regionKeys[0]: "us-west-2", + ] + let resolved = try await BedrockCredentialResolver.resolve(environment: env) + #expect(resolved.credentials.accessKeyID == "AKIAKEYS") + #expect(resolved.credentials.secretAccessKey == "keys-secret") + #expect(resolved.region == "us-west-2") + } + + @Test + func `keys mode without credentials throws missingCredentials`() async { + await #expect(throws: BedrockUsageError.missingCredentials) { + try await BedrockCredentialResolver.resolve(environment: [:]) + } + } + + @Test + func `profile mode resolves credentials via the AWS CLI`() async throws { + let env = [ + BedrockSettingsReader.authModeKey: "profile", + BedrockSettingsReader.profileKey: "work", + ] + let resolved = try await BedrockCredentialResolver.resolve( + environment: env, + resolveAWSBinary: { _ in "/usr/bin/aws" }, + makeProvider: { _ in self.profileProvider() }) + #expect(resolved.credentials.accessKeyID == "AKIAPROFILE") + #expect(resolved.credentials.sessionToken == "profile-token") + // No explicit region in env, so it is derived from the profile. + #expect(resolved.region == "ap-southeast-2") + } + + @Test + func `profile mode prefers explicit region over the profile region`() async throws { + let env = [ + BedrockSettingsReader.authModeKey: "profile", + BedrockSettingsReader.profileKey: "work", + BedrockSettingsReader.regionKeys[0]: "eu-central-1", + ] + let resolved = try await BedrockCredentialResolver.resolve( + environment: env, + resolveAWSBinary: { _ in "/usr/bin/aws" }, + makeProvider: { _ in self.profileProvider() }) + #expect(resolved.region == "eu-central-1") + } + + @Test + func `profile mode without a profile name throws missingCredentials`() async { + let env = [BedrockSettingsReader.authModeKey: "profile"] + await #expect(throws: BedrockUsageError.missingCredentials) { + try await BedrockCredentialResolver.resolve( + environment: env, + resolveAWSBinary: { _ in "/usr/bin/aws" }, + makeProvider: { _ in self.profileProvider() }) + } + } + + @Test + func `profile mode preserves source credentials but removes AWS_PROFILE for AWS CLI`() async throws { + let captured = CapturedEnvironment() + let env = [ + BedrockSettingsReader.authModeKey: "profile", + BedrockSettingsReader.profileKey: "work", + BedrockSettingsReader.accessKeyIDKey: "AKIAINHERITED", + BedrockSettingsReader.secretAccessKeyKey: "inherited-secret", + BedrockSettingsReader.sessionTokenKey: "inherited-token", + ] + _ = try await BedrockCredentialResolver.resolve( + environment: env, + resolveAWSBinary: { _ in "/usr/bin/aws" }, + makeProvider: { _ in + BedrockProfileCredentialProvider(awsBinaryPath: "/usr/bin/aws") { arguments, environment in + captured.record(environment) + if arguments.contains("export-credentials") { + return SubprocessResult(stdout: Self.credentialsJSON, stderr: "") + } + return SubprocessResult(stdout: "us-east-1\n", stderr: "") + } + }) + let seen = captured.value + #expect(seen[BedrockSettingsReader.accessKeyIDKey] == "AKIAINHERITED") + #expect(seen[BedrockSettingsReader.secretAccessKeyKey] == "inherited-secret") + #expect(seen[BedrockSettingsReader.sessionTokenKey] == "inherited-token") + #expect(seen[BedrockSettingsReader.profileKey] == nil) + } + + @Test + func `profile mode without the AWS CLI throws awsCLINotFound`() async { + let env = [ + BedrockSettingsReader.authModeKey: "profile", + BedrockSettingsReader.profileKey: "work", + ] + await #expect(throws: BedrockUsageError.awsCLINotFound) { + try await BedrockCredentialResolver.resolve( + environment: env, + resolveAWSBinary: { _ in nil }, + makeProvider: { _ in self.profileProvider() }) + } + } +} diff --git a/Tests/CodexBarTests/BedrockMenuCardTests.swift b/Tests/CodexBarTests/BedrockMenuCardTests.swift index e3a4e8a5..60ca9b9d 100644 --- a/Tests/CodexBarTests/BedrockMenuCardTests.swift +++ b/Tests/CodexBarTests/BedrockMenuCardTests.swift @@ -13,6 +13,7 @@ struct BedrockMenuCardTests { sessionCostUSD: 12.34, last30DaysTokens: nil, last30DaysCostUSD: 56.78, + historyDays: 7, daily: [ CostUsageDailyReport.Entry( date: "2026-05-12", @@ -46,6 +47,7 @@ struct BedrockMenuCardTests { #expect(model.tokenUsage?.sessionLine == "Latest billing day (May 12): $12.34") #expect(model.tokenUsage?.sessionLine.contains("Today") == false) - #expect(model.tokenUsage?.hintLine == "Reported by AWS Cost Explorer; daily billing data can lag.") + #expect(model.tokenUsage?.monthLine == "Last 7 days: $56.78") + #expect(model.tokenUsage?.hintLine == "AWS Cost Explorer billing can lag.") } } diff --git a/Tests/CodexBarTests/BedrockProfileCredentialProviderTests.swift b/Tests/CodexBarTests/BedrockProfileCredentialProviderTests.swift new file mode 100644 index 00000000..f8861bcd --- /dev/null +++ b/Tests/CodexBarTests/BedrockProfileCredentialProviderTests.swift @@ -0,0 +1,93 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct BedrockProfileCredentialProviderTests { + private func provider( + stdout: String = "", + stderr: String = "", + throwsNonZero: Bool = false) -> BedrockProfileCredentialProvider + { + BedrockProfileCredentialProvider(awsBinaryPath: "/usr/bin/aws") { _, _ in + if throwsNonZero { + throw SubprocessRunnerError.nonZeroExit(code: 1, stderr: stderr) + } + return SubprocessResult(stdout: stdout, stderr: stderr) + } + } + + @Test + func `parses export-credentials json with session token`() async throws { + let json = """ + {"Version":1,"AccessKeyId":"AKIA","SecretAccessKey":"secret",\ + "SessionToken":"token","Expiration":"2026-05-27T12:00:00Z"} + """ + let creds = try await provider(stdout: json).exportCredentials(profile: "work") + #expect(creds.accessKeyID == "AKIA") + #expect(creds.secretAccessKey == "secret") + #expect(creds.sessionToken == "token") + } + + @Test + func `parses export-credentials json without session token`() async throws { + let json = #"{"Version":1,"AccessKeyId":"AKIA","SecretAccessKey":"secret"}"# + let creds = try await provider(stdout: json).exportCredentials(profile: "work") + #expect(creds.accessKeyID == "AKIA") + #expect(creds.sessionToken == nil) + } + + @Test + func `maps expired SSO stderr to profileSessionExpired`() async { + let stderr = "The SSO session associated with this profile has expired. " + + "To refresh this SSO session run aws sso login with the corresponding profile." + let sut = self.provider(stderr: stderr, throwsNonZero: true) + await #expect(throws: BedrockUsageError.profileSessionExpired("work")) { + try await sut.exportCredentials(profile: "work") + } + } + + @Test + func `maps other non-zero exit to apiError`() async { + let sut = self.provider(stderr: "The config profile (work) could not be found", throwsNonZero: true) + do { + _ = try await sut.exportCredentials(profile: "work") + Issue.record("expected an error") + } catch let error as BedrockUsageError { + if case .apiError = error { } else { Issue.record("expected apiError, got \(error)") } + } catch { + Issue.record("unexpected error type: \(error)") + } + } + + @Test + func `malformed json throws parseFailed`() async { + let sut = self.provider(stdout: "not json") + do { + _ = try await sut.exportCredentials(profile: "work") + Issue.record("expected an error") + } catch let error as BedrockUsageError { + if case .parseFailed = error { } else { Issue.record("expected parseFailed, got \(error)") } + } catch { + Issue.record("unexpected error type: \(error)") + } + } + + @Test + func `resolveRegion returns trimmed value`() async throws { + let region = try await provider(stdout: "eu-west-1\n").resolveRegion(profile: "work") + #expect(region == "eu-west-1") + } + + @Test + func `resolveRegion returns nil when unset (non-zero exit)`() async throws { + let region = try await provider(throwsNonZero: true).resolveRegion(profile: "work") + #expect(region == nil) + } + + @Test + func `resolveRegion returns nil for empty output`() async throws { + let region = try await provider(stdout: "\n").resolveRegion(profile: "work") + #expect(region == nil) + } +} diff --git a/Tests/CodexBarTests/BedrockSettingsFlowTests.swift b/Tests/CodexBarTests/BedrockSettingsFlowTests.swift index f14a3ca3..526eeb80 100644 --- a/Tests/CodexBarTests/BedrockSettingsFlowTests.swift +++ b/Tests/CodexBarTests/BedrockSettingsFlowTests.swift @@ -70,4 +70,34 @@ struct BedrockSettingsFlowTests { settings: settings, environment: env))) } + + @Test + func `profile mode maps profile into provider environment and is available`() throws { + let suite = "BedrockSettingsFlowTests-profile-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + settings.bedrockAuthMode = BedrockAuthMode.profile.rawValue + settings.bedrockProfile = "work" + + let config = try #require(settings.providerConfig(for: .bedrock)) + #expect(config.sanitizedAWSAuthMode == "profile") + #expect(config.sanitizedAWSProfile == "work") + + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .bedrock, + settings: settings, + tokenOverride: nil) + + #expect(env[BedrockSettingsReader.authModeKey] == "profile") + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == nil) + #expect(BedrockSettingsReader.hasCredentials(environment: env)) + } } diff --git a/Tests/CodexBarTests/BedrockSettingsReaderTests.swift b/Tests/CodexBarTests/BedrockSettingsReaderTests.swift new file mode 100644 index 00000000..e8a119a1 --- /dev/null +++ b/Tests/CodexBarTests/BedrockSettingsReaderTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct BedrockSettingsReaderTests { + @Test + func `default auth mode is keys`() { + #expect(BedrockSettingsReader.authMode(environment: [:]) == .keys) + } + + @Test + func `explicit profile auth mode wins`() { + let env = ["CODEXBAR_BEDROCK_AUTH_MODE": "profile"] + #expect(BedrockSettingsReader.authMode(environment: env) == .profile) + } + + @Test + func `AWS_PROFILE without keys implies profile mode`() { + let env = ["AWS_PROFILE": "work"] + #expect(BedrockSettingsReader.authMode(environment: env) == .profile) + #expect(BedrockSettingsReader.profile(environment: env) == "work") + } + + @Test + func `AWS_PROFILE alongside static keys keeps keys mode`() { + let env = [ + "AWS_PROFILE": "work", + "AWS_ACCESS_KEY_ID": "AKIA", + "AWS_SECRET_ACCESS_KEY": "secret", + ] + #expect(BedrockSettingsReader.authMode(environment: env) == .keys) + } + + @Test + func `hasCredentials in profile mode requires a profile name`() { + let withProfile = ["CODEXBAR_BEDROCK_AUTH_MODE": "profile", "AWS_PROFILE": "work"] + let withoutProfile = ["CODEXBAR_BEDROCK_AUTH_MODE": "profile"] + #expect(BedrockSettingsReader.hasCredentials(environment: withProfile)) + #expect(!BedrockSettingsReader.hasCredentials(environment: withoutProfile)) + } + + @Test + func `hasCredentials in keys mode requires both keys`() { + let both = ["AWS_ACCESS_KEY_ID": "AKIA", "AWS_SECRET_ACCESS_KEY": "secret"] + let onlyAccess = ["AWS_ACCESS_KEY_ID": "AKIA"] + #expect(BedrockSettingsReader.hasCredentials(environment: both)) + #expect(!BedrockSettingsReader.hasCredentials(environment: onlyAccess)) + } +} diff --git a/Tests/CodexBarTests/BrowserDetectionTests.swift b/Tests/CodexBarTests/BrowserDetectionTests.swift index 4ce346e2..f6a16d50 100644 --- a/Tests/CodexBarTests/BrowserDetectionTests.swift +++ b/Tests/CodexBarTests/BrowserDetectionTests.swift @@ -5,6 +5,7 @@ import Testing #if os(macOS) import SweetCookieKit +@Suite(.serialized) struct BrowserDetectionTests { @Test func `safari always installed`() { @@ -103,27 +104,73 @@ struct BrowserDetectionTests { let start = Date(timeIntervalSince1970: 1000) var preflightCount = 0 + KeychainAccessGate.withTaskOverrideForTesting(false) { + ProviderInteractionContext.$current.withValue(.userInitiated) { + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + preflightCount += 1 + return .interactionRequired + } operation: { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == false) + } + + KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in + preflightCount += 1 + return .allowed + } operation: { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false) + #expect( + BrowserCookieAccessGate.shouldAttempt( + .chrome, + now: start.addingTimeInterval((60 * 60 * 6) + 1)) == true) + } + } + } + + #expect(preflightCount == 2) + } + + @Test + func `background cookie import allows authorized chromium keychain sources`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + var preflightCount = 0 + KeychainAccessGate.withTaskOverrideForTesting(false) { KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in preflightCount += 1 - return .interactionRequired + return .allowed } operation: { - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start) == false) + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == true) + #expect(BrowserCookieAccessGate.shouldAttempt(.safari) == true) + } } + } + + #expect(preflightCount == 1) + } + + @Test + func `background cookie import suppresses chromium keychain sources requiring interaction`() { + BrowserCookieAccessGate.resetForTesting() + defer { BrowserCookieAccessGate.resetForTesting() } + + var preflightCount = 0 + KeychainAccessGate.withTaskOverrideForTesting(false) { KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting { _, _ in preflightCount += 1 - return .allowed + return .interactionRequired } operation: { - #expect(BrowserCookieAccessGate.shouldAttempt(.chrome, now: start.addingTimeInterval(60)) == false) - #expect( - BrowserCookieAccessGate.shouldAttempt( - .chrome, - now: start.addingTimeInterval((60 * 60 * 6) + 1)) == true) + ProviderInteractionContext.$current.withValue(.background) { + #expect(BrowserCookieAccessGate.shouldAttempt(.chrome) == false) + #expect(BrowserCookieAccessGate.shouldAttempt(.safari) == true) + } } } - #expect(preflightCount == 2) + #expect(preflightCount == 1) } @Test diff --git a/Tests/CodexBarTests/CLIArgumentParsingTests.swift b/Tests/CodexBarTests/CLIArgumentParsingTests.swift index 13bc0453..86377e8a 100644 --- a/Tests/CodexBarTests/CLIArgumentParsingTests.swift +++ b/Tests/CodexBarTests/CLIArgumentParsingTests.swift @@ -64,4 +64,24 @@ struct CLIArgumentParsingTests { #expect(!parsed.flags.contains("jsonOutput")) #expect(CodexBarCLI._decodeFormatForTesting(from: parsed) == .json) } + + @Test + func `diagnose accepts json output flag but discards provider logs`() throws { + let signature = CodexBarCLI._diagnoseSignatureForTesting() + let parser = CommandParser(signature: signature) + let parsed = try parser.parse(arguments: [ + "--provider", "minimax", + "--format", "json", + "--json-output", + ]) + + #expect(parsed.flags.contains("jsonOutput")) + let config = CodexBarCLI.loggingConfiguration(path: ["diagnose"], values: parsed) + switch config.destination { + case .discard: + break + case .stderr, .oslog: + Issue.record("diagnose should not emit provider logs beside the safe JSON export") + } + } } diff --git a/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift new file mode 100644 index 00000000..e2a27a7d --- /dev/null +++ b/Tests/CodexBarTests/CLIDiagnoseCommandTests.swift @@ -0,0 +1,125 @@ +import CodexBarCore +import Testing +@testable import CodexBarCLI + +struct CLIDiagnoseCommandTests { + @Test + func `diagnose help describes generic JSON export`() { + let help = CodexBarCLI.diagnoseHelp(version: "0.0.0") + + #expect(help.contains("codexbar diagnose --provider --format json")) + #expect(help.contains("codexbar diagnose --provider all --format json")) + #expect(help.contains("safe JSON export")) + #expect(help.contains("raw API tokens")) + } + + private func makeSettingsWithMiniMaxCookie(_ manualCookieHeader: String) -> ProviderSettingsSnapshot { + ProviderSettingsSnapshot( + debugMenuEnabled: false, + debugKeepCLISessionsAlive: false, + codex: nil, + claude: nil, + cursor: nil, + opencode: nil, + opencodego: nil, + alibaba: nil, + factory: nil, + minimax: ProviderSettingsSnapshot.MiniMaxProviderSettings( + cookieSource: .manual, + manualCookieHeader: manualCookieHeader, + apiRegion: .global), + manus: nil, + zai: nil, + copilot: nil, + kilo: nil, + kimi: nil, + augment: nil, + amp: nil, + ollama: nil) + } + + @Test + func `diagnose auth mode uses settings-backed MiniMax manual cookie when env token is absent`() { + let settings = self.makeSettingsWithMiniMaxCookie("Cookie: session_id=demo-cookie") + + let authMode = CodexBarCLI._resolveMiniMaxAuthModeForTesting( + environment: [:], + settings: settings) + + #expect(authMode == .cookie) + } + + @Test + func `diagnose auth mode keeps apiToken precedence over settings cookie`() { + let settings = self.makeSettingsWithMiniMaxCookie("Cookie: session_id=demo-cookie") + + let authMode = CodexBarCLI._resolveMiniMaxAuthModeForTesting( + environment: [MiniMaxAPISettingsReader.apiTokenKey: "sk-api-demo-token"], + settings: settings) + + #expect(authMode == .apiToken) + } + + @Test + func `generic diagnose auth summary detects provider config`() { + let summary = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .openai, + account: nil, + config: ProviderConfig(id: .openai, apiKey: "sk-test"), + environment: [:], + settings: nil) + + #expect(summary.configured) + #expect(summary.modes == ["api"]) + } + + @Test + func `generic diagnose auth summary detects provider environment credentials`() { + let summary = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .openai, + account: nil, + config: nil, + environment: [OpenAIAPISettingsReader.apiKeyEnvironmentKey: "sk-test"], + settings: nil) + + #expect(summary.configured) + #expect(summary.modes == ["api"]) + } + + @Test + func `generic diagnose auth summary requires complete Bedrock credentials`() { + let partial = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .bedrock, + account: nil, + config: ProviderConfig(id: .bedrock, apiKey: "access-only"), + environment: [BedrockSettingsReader.accessKeyIDKey: "access-only"], + settings: nil) + #expect(!partial.configured) + #expect(partial.modes.isEmpty) + + let complete = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .bedrock, + account: nil, + config: nil, + environment: [ + BedrockSettingsReader.accessKeyIDKey: "access", + BedrockSettingsReader.secretAccessKeyKey: "secret", + ], + settings: nil) + #expect(complete.configured) + #expect(complete.modes == ["api"]) + } + + @Test + func `generic diagnose auth summary does not assume ambient credentials`() { + let summary = CodexBarCLI._diagnosticAuthSummaryForTesting( + provider: .codex, + account: nil, + config: nil, + environment: [:], + settings: nil) + + #expect(!summary.configured) + #expect(summary.modes.isEmpty) + } +} diff --git a/Tests/CodexBarTests/CLIServeRouterTests.swift b/Tests/CodexBarTests/CLIServeRouterTests.swift index 84149ed5..4b21f61b 100644 --- a/Tests/CodexBarTests/CLIServeRouterTests.swift +++ b/Tests/CodexBarTests/CLIServeRouterTests.swift @@ -105,6 +105,27 @@ struct CLIServeRouterTests { positional: [], options: [:], flags: [])) == 60) + + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: ["requestTimeout": ["soon"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: ["requestTimeout": ["-0.5"]], + flags: [])) == nil) + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: ["requestTimeout": ["0"]], + flags: [])) == 0) + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: ["requestTimeout": ["12.5"]], + flags: [])) == 12.5) + #expect(CodexBarCLI.decodeServeRequestTimeout(from: ParsedValues( + positional: [], + options: [:], + flags: [])) == 30) } @Test @@ -124,6 +145,139 @@ struct CLIServeRouterTests { #expect(!CodexBarCLI.shouldCacheServeResponse(routeError)) } + @Test + func `serve cache coalesces concurrent cache misses`() async { + let cache = CLIServeResponseCache() + let counter = ServeTestCounter() + + let responses = await withTaskGroup(of: CLILocalHTTPResponse.self) { group -> [CLILocalHTTPResponse] in + for _ in 0..<5 { + group.addTask { + await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 1) + { + let call = await counter.increment() + try? await Task.sleep(nanoseconds: 50_000_000) + return Self.response("[{\"provider\":\"codex\",\"call\":\(call)}]") + } + } + } + + var responses: [CLILocalHTTPResponse] = [] + for await response in group { + responses.append(response) + } + return responses + } + + #expect(await counter.current() == 1) + #expect(Set(responses.map(Self.bodyString)).count == 1) + #expect(responses.allSatisfy { $0.status == .ok }) + #expect(responses.allSatisfy { Self.bodyString($0).contains("\"call\":1") }) + } + + @Test + func `serve cache does not cache timeouts and recovers on next success`() async { + let cache = CLIServeResponseCache() + let counter = ServeTestCounter() + + let timeout = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 0.01) + { + _ = await counter.increment() + try? await Task.sleep(nanoseconds: 200_000_000) + return Self.response("[{\"provider\":\"codex\",\"call\":1}]") + } + + #expect(timeout.status == .gatewayTimeout) + #expect(Self.bodyString(timeout).contains("request timed out")) + + let success = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 1) + { + let call = await counter.increment() + return Self.response("[{\"provider\":\"codex\",\"call\":\(call)}]") + } + + #expect(success.status == .ok) + #expect(Self.bodyString(success).contains("\"call\":2")) + + let cached = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 1) + { + let call = await counter.increment() + return Self.response("[{\"provider\":\"codex\",\"call\":\(call)}]") + } + + #expect(cached.status == .ok) + #expect(Self.bodyString(cached) == Self.bodyString(success)) + #expect(await counter.current() == 2) + } + + @Test + func `serve cache resumes coalesced waiters on timeout`() async { + let cache = CLIServeResponseCache() + let counter = ServeTestCounter() + + let responses = await withTaskGroup(of: CLILocalHTTPResponse.self) { group -> [CLILocalHTTPResponse] in + for _ in 0..<4 { + group.addTask { + await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 60, + requestTimeout: 0.01) + { + _ = await counter.increment() + try? await Task.sleep(nanoseconds: 200_000_000) + return Self.response("[{\"provider\":\"codex\"}]") + } + } + } + + var responses: [CLILocalHTTPResponse] = [] + for await response in group { + responses.append(response) + } + return responses + } + + #expect(await counter.current() == 1) + #expect(responses.count == 4) + #expect(responses.allSatisfy { $0.status == .gatewayTimeout }) + #expect(responses.allSatisfy { Self.bodyString($0).contains("request timed out") }) + } + + @Test + func `serve request timeout zero disables the deadline`() async { + let cache = CLIServeResponseCache() + + let response = await CodexBarCLI.cachedServeResponse( + key: "usage:", + cache: cache, + refreshInterval: 0, + requestTimeout: 0) + { + try? await Task.sleep(nanoseconds: 80_000_000) + return Self.response("[{\"provider\":\"codex\",\"slow\":true}]") + } + + #expect(response.status == .ok) + #expect(Self.bodyString(response).contains("\"slow\":true")) + } + private static func parsedRequest(host: String) throws -> CLILocalHTTPRequest { let raw = "GET /usage?provider=claude HTTP/1.1\r\nHost: \(host)\r\n\r\n" return try CLILocalHTTPRequest.parse(Data(raw.utf8)).get() @@ -137,4 +291,25 @@ struct CLIServeRouterTests { #expect(error == expected) } } + + private static func response(_ body: String, status: CLIHTTPStatus = .ok) -> CLILocalHTTPResponse { + CLILocalHTTPResponse(status: status, body: Data(body.utf8)) + } + + private static func bodyString(_ response: CLILocalHTTPResponse) -> String { + String(data: response.body, encoding: .utf8) ?? "" + } +} + +private actor ServeTestCounter { + private var value = 0 + + func increment() -> Int { + self.value += 1 + return self.value + } + + func current() -> Int { + self.value + } } diff --git a/Tests/CodexBarTests/CLISnapshotTests.swift b/Tests/CodexBarTests/CLISnapshotTests.swift index 7df099b3..173136e0 100644 --- a/Tests/CodexBarTests/CLISnapshotTests.swift +++ b/Tests/CodexBarTests/CLISnapshotTests.swift @@ -378,6 +378,64 @@ struct CLISnapshotTests { #expect(output.contains("Pace:")) } + @Test + func `renders Ollama weekly pace line when weekly window has reset`() { + let now = Date() + let snap = UsageSnapshot( + primary: .init( + usedPercent: 0, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(4 * 3600), + resetDescription: nil), + secondary: .init( + usedPercent: 23, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(5 * 24 * 3600), + resetDescription: nil), + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .ollama, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Ollama (web)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(output.contains("Weekly: 77% left")) + #expect(output.contains("Pace: 6% in reserve | Expected 29% used | Lasts until reset")) + } + + @Test + func `hides Ollama weekly pace when weekly duration is missing`() { + let now = Date() + let snap = UsageSnapshot( + primary: nil, + secondary: .init( + usedPercent: 23, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(5 * 24 * 3600), + resetDescription: nil), + tertiary: nil, + updatedAt: now) + + let output = CLIRenderer.renderText( + provider: .ollama, + snapshot: snap, + credits: nil, + context: RenderContext( + header: "Ollama (web)", + status: nil, + useColor: false, + resetStyle: .countdown)) + + #expect(output.contains("Weekly: 77% left")) + #expect(!output.contains("Pace:")) + } + @Test func `renders JSON payload`() throws { let snap = UsageSnapshot( diff --git a/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift b/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift new file mode 100644 index 00000000..8b2375ac --- /dev/null +++ b/Tests/CodexBarTests/ClaudeDirectUsageFallbackTests.swift @@ -0,0 +1,152 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ClaudeDirectUsageFallbackTests { + private final class InvocationLog: @unchecked Sendable { + private let url: URL + private let lock = NSLock() + + init(url: URL) { + self.url = url + } + + func contents() -> String { + self.lock.withLock { + (try? String(contentsOf: self.url, encoding: .utf8)) ?? "" + } + } + } + + @Test + func `cli source falls back to direct usage when pty usage fails to load`() async throws { + let cliLogURL = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-direct-fallback-log-\(UUID().uuidString).txt") + let log = InvocationLog(url: cliLogURL) + let fakeCLI = try Self.makeDirectFallbackClaudeCLI(logURL: cliLogURL) + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [ + "CLAUDE_CLI_PATH": fakeCLI.path, + ClaudeOAuthCredentialsStore.environmentTokenKey: "oauth-token", + ClaudeOAuthCredentialsStore.environmentScopesKey: "user:profile", + "ANTHROPIC_ADMIN_KEY": "admin-token", + ], + dataSource: .cli) + + try await ClaudeCLISession.withIsolatedSessionForTesting { + try await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI.path) { + do { + _ = try await fetcher.loadLatestUsage(model: "sonnet") + #expect(Bool(false), "Subscription-only usage should fail parsing") + } catch let ClaudeUsageError.parseFailed(message) { + #expect(message.lowercased().contains("subscription")) + } catch let ClaudeStatusProbeError.parseFailed(message) { + #expect(message.lowercased().contains("subscription")) + } + } + } + + let invocations = log.contents() + #expect(invocations.contains("pty-usage")) + #expect(invocations.contains("direct-usage")) + #expect(!invocations.contains("pty-secret-env")) + #expect(!invocations.contains("direct-secret-env")) + } + + @Test + func `direct usage timeout keeps original pty failure`() async throws { + let cliLogURL = FileManager.default.temporaryDirectory + .appendingPathComponent("claude-direct-timeout-log-\(UUID().uuidString).txt") + let log = InvocationLog(url: cliLogURL) + let fakeCLI = try Self.makeDirectTimeoutClaudeCLI(logURL: cliLogURL) + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: ["CLAUDE_CLI_PATH": fakeCLI.path], + dataSource: .cli) + + await ClaudeCLISession.withIsolatedSessionForTesting { + await ClaudeCLIResolver.withResolvedBinaryPathOverrideForTesting(fakeCLI.path) { + do { + _ = try await fetcher.loadLatestUsage(model: "sonnet") + #expect(Bool(false), "PTY failure should still surface") + } catch let ClaudeStatusProbeError.parseFailed(message) { + #expect(message.lowercased().contains("could not load usage data")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + } + + let invocations = log.contents() + #expect(invocations.contains("pty-usage")) + #expect(invocations.contains("direct-usage")) + } + + private static func makeDirectFallbackClaudeCLI(logURL: URL) throws -> URL { + try self.makeClaudeCLI(name: "claude-direct-fallback", logURL: logURL, scriptBody: """ + if [ "$1" = "/usage" ]; then + printf 'direct-usage\\n' >> "$LOG_FILE" + if [ -n "$CODEXBAR_CLAUDE_OAUTH_TOKEN" ] || + [ -n "$CODEXBAR_CLAUDE_OAUTH_SCOPES" ] || + [ -n "$ANTHROPIC_ADMIN_KEY" ]; then + printf 'direct-secret-env\\n' >> "$LOG_FILE" + fi + printf '%s\\n' 'You are currently using your subscription to power your Claude Code usage' + exit 0 + fi + while IFS= read -r line; do + case "$line" in + *"/usage"*) + printf 'pty-usage\\n' >> "$LOG_FILE" + if [ -n "$CODEXBAR_CLAUDE_OAUTH_TOKEN" ] || + [ -n "$CODEXBAR_CLAUDE_OAUTH_SCOPES" ] || + [ -n "$ANTHROPIC_ADMIN_KEY" ]; then + printf 'pty-secret-env\\n' >> "$LOG_FILE" + fi + printf '%s\\n' 'Failed to load usage data' + ;; + *"/status"*) + printf 'pty-status\\n' >> "$LOG_FILE" + printf '%s\\n' 'Account: subscription@example.com' + ;; + esac + done + """) + } + + private static func makeDirectTimeoutClaudeCLI(logURL: URL) throws -> URL { + try self.makeClaudeCLI(name: "claude-direct-timeout", logURL: logURL, scriptBody: """ + if [ "$1" = "/usage" ]; then + printf 'direct-usage\\n' >> "$LOG_FILE" + sleep 30 + exit 0 + fi + while IFS= read -r line; do + case "$line" in + *"/usage"*) + printf 'pty-usage\\n' >> "$LOG_FILE" + printf '%s\\n' 'Failed to load usage data' + ;; + esac + done + """) + } + + private static func makeClaudeCLI(name: String, logURL: URL, scriptBody: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("\(name)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let scriptURL = directory.appendingPathComponent("claude") + let script = """ + #!/bin/sh + LOG_FILE='\(logURL.path)' + \(scriptBody) + """ + try script.write(to: scriptURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o755))], + ofItemAtPath: scriptURL.path) + return scriptURL + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift new file mode 100644 index 00000000..65dcba04 --- /dev/null +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests.swift @@ -0,0 +1,254 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct ClaudeOAuthCredentialsStoreCLIStorageOwnershipTests { + private func makeCredentialsData(accessToken: String, expiresAt: Date, refreshToken: String? = nil) -> Data { + let millis = Int(expiresAt.timeIntervalSince1970 * 1000) + let refreshField: String = { + guard let refreshToken else { return "" } + return ",\n \"refreshToken\": \"\(refreshToken)\"" + }() + let json = """ + { + "claudeAiOauth": { + "accessToken": "\(accessToken)", + "expiresAt": \(millis), + "scopes": ["user:profile"]\(refreshField) + } + } + """ + return Data(json.utf8) + } + + @Test + func `load record treats codexbar cache as claude CLI owned when credentials file exists`() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let fileData = self.makeCredentialsData( + accessToken: "claude-cli-file", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "cli-refresh-token") + try fileData.write(to: fileURL) + + let cachedData = self.makeCredentialsData( + accessToken: "codexbar-cache", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "cached-refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: cachedData, + storedAt: Date(timeIntervalSinceNow: 60), + owner: .codexbar)) + + let record = try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + + #expect(record.credentials.accessToken == "codexbar-cache") + #expect(record.owner == .claudeCLI) + #expect(record.source == .cacheKeychain) + } + } + } + } + } + } + + @Test + func `load with auto refresh delegates expired codexbar cache when credentials file exists`() async throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + try await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + try Data("not valid credentials".utf8).write(to: fileURL) + + let expiredData = self.makeCredentialsData( + accessToken: "expired-codexbar-with-file", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: "cached-refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(timeIntervalSinceNow: 60), + owner: .codexbar)) + + await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) { + do { + _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + Issue.record("Expected delegated refresh error when Claude CLI file is present") + } catch let error as ClaudeOAuthCredentialsError { + guard case .refreshDelegatedToClaudeCLI = error else { + Issue.record("Expected .refreshDelegatedToClaudeCLI, got \(error)") + return + } + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + } + } + } + } + } + } + } + + @Test + func `load with auto refresh keeps codexbar cache ownership without Claude CLI storage`() async throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try await KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let fileURL = tempDir.appendingPathComponent("missing-credentials.json") + await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + await ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + await ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let expiredData = self.makeCredentialsData( + accessToken: "expired-codexbar-only", + expiresAt: Date(timeIntervalSinceNow: -3600), + refreshToken: "cached-refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: expiredData, + storedAt: Date(timeIntervalSinceNow: 60), + owner: .codexbar)) + + await ClaudeOAuthRefreshFailureGate.$shouldAttemptOverride.withValue(false) { + do { + _ = try await ClaudeOAuthCredentialsStore.loadWithAutoRefresh( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true) + Issue.record("Expected direct CodexBar refresh failure") + } catch let error as ClaudeOAuthCredentialsError { + guard case let .refreshFailed(message) = error else { + Issue.record("Expected .refreshFailed, got \(error)") + return + } + #expect(message.contains("suppressed") || message.contains("backed off")) + } catch { + Issue.record("Expected ClaudeOAuthCredentialsError, got \(error)") + } + } + } + } + } + } + } + } + + @Test + func `load record treats codexbar cache as claude CLI owned when Claude keychain item exists`() throws { + let service = "com.steipete.codexbar.cache.tests.\(UUID().uuidString)" + try KeychainCacheStore.withServiceOverrideForTesting(service) { + KeychainCacheStore.setTestStoreForTesting(true) + defer { KeychainCacheStore.setTestStoreForTesting(false) } + + ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() + defer { ClaudeOAuthCredentialsStore._resetCredentialsFileTrackingForTesting() } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + ClaudeOAuthCredentialsStore.invalidateCache() + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + defer { KeychainCacheStore.clear(key: cacheKey) } + + let cachedData = self.makeCredentialsData( + accessToken: "codexbar-cache", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "cached-refresh-token") + KeychainCacheStore.store( + key: cacheKey, + entry: ClaudeOAuthCredentialsStore.CacheEntry( + data: cachedData, + storedAt: Date(), + owner: .codexbar)) + + let keychainData = self.makeCredentialsData( + accessToken: "claude-keychain", + expiresAt: Date(timeIntervalSinceNow: 3600), + refreshToken: "keychain-refresh-token") + + let record = try ClaudeOAuthKeychainPromptPreference.withTaskOverrideForTesting(.never) { + try ClaudeOAuthCredentialsStore.withClaudeKeychainOverridesForTesting( + data: keychainData, + fingerprint: nil) + { + try ClaudeOAuthCredentialsStore.loadRecord( + environment: [:], + allowKeychainPrompt: false, + respectKeychainPromptCooldown: true, + allowClaudeKeychainRepairWithoutPrompt: false) + } + } + + #expect(record.credentials.accessToken == "codexbar-cache") + #expect(record.owner == .claudeCLI) + #expect(record.source == .cacheKeychain) + } + } + } + } + } +} diff --git a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift index 319c1676..ff4fddba 100644 --- a/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthCredentialsStoreTests.swift @@ -160,32 +160,36 @@ struct ClaudeOAuthCredentialsStoreTests { // Avoid interacting with the real Keychain in unit tests. try ClaudeOAuthCredentialsStore.withKeychainAccessOverrideForTesting(true) { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let fileURL = tempDir.appendingPathComponent("credentials.json") - try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { - let first = self.makeCredentialsData( - accessToken: "first", - expiresAt: Date(timeIntervalSinceNow: 3600)) - try first.write(to: fileURL) + try ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + try ClaudeOAuthCredentialsStore.withIsolatedMemoryCacheForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("credentials.json") + try ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let first = self.makeCredentialsData( + accessToken: "first", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try first.write(to: fileURL) - let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) - let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: first, storedAt: Date()) - KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) + let cacheKey = KeychainCacheStore.Key.oauth(provider: .claude) + let cacheEntry = ClaudeOAuthCredentialsStore.CacheEntry(data: first, storedAt: Date()) + KeychainCacheStore.store(key: cacheKey, entry: cacheEntry) - _ = try ClaudeOAuthCredentialsStore.load(environment: [:]) + _ = try ClaudeOAuthCredentialsStore.load(environment: [:]) - let updated = self.makeCredentialsData( - accessToken: "second", - expiresAt: Date(timeIntervalSinceNow: 3600)) - try updated.write(to: fileURL) + let updated = self.makeCredentialsData( + accessToken: "second", + expiresAt: Date(timeIntervalSinceNow: 3600)) + try updated.write(to: fileURL) - #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) - KeychainCacheStore.clear(key: cacheKey) + #expect(ClaudeOAuthCredentialsStore.invalidateCacheIfCredentialsFileChanged()) + KeychainCacheStore.clear(key: cacheKey) - let creds = try ClaudeOAuthCredentialsStore.load(environment: [:]) - #expect(creds.accessToken == "second") + let creds = try ClaudeOAuthCredentialsStore.load(environment: [:]) + #expect(creds.accessToken == "second") + } + } } } } diff --git a/Tests/CodexBarTests/ClaudeOAuthTests.swift b/Tests/CodexBarTests/ClaudeOAuthTests.swift index e180075d..a08fec11 100644 --- a/Tests/CodexBarTests/ClaudeOAuthTests.swift +++ b/Tests/CodexBarTests/ClaudeOAuthTests.swift @@ -98,7 +98,7 @@ struct ClaudeOAuthTests { } @Test - func `maps O auth design and routines usage windows`() throws { + func `ignores merged O auth design usage window`() throws { let json = """ { "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, @@ -107,15 +107,14 @@ struct ClaudeOAuthTests { } """ let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) - #expect(snap.extraRateWindows.count == 2) - #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.title == "Designs") - #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 44) + #expect(snap.extraRateWindows.count == 1) + #expect(snap.extraRateWindows.contains { $0.id == "claude-design" } == false) #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.title == "Daily Routines") #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 18) } @Test - func `maps O auth omelette and cowork usage windows`() throws { + func `ignores merged O auth omelette usage window`() throws { let json = """ { "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, @@ -124,8 +123,8 @@ struct ClaudeOAuthTests { } """ let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) - #expect(snap.extraRateWindows.count == 2) - #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 29) + #expect(snap.extraRateWindows.count == 1) + #expect(snap.extraRateWindows.contains { $0.id == "claude-design" } == false) #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 9) } @@ -140,10 +139,11 @@ struct ClaudeOAuthTests { """ let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) + #expect(snap.extraRateWindows.contains { $0.id == "claude-design" } == false) } @Test - func `prefers populated alias over null alias in mixed payload`() throws { + func `prefers populated routines alias over null alias in mixed payload`() throws { let json = """ { "five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }, @@ -154,7 +154,7 @@ struct ClaudeOAuthTests { } """ let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) - #expect(snap.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 37) + #expect(snap.extraRateWindows.contains { $0.id == "claude-design" } == false) #expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 14) } @@ -200,15 +200,15 @@ struct ClaudeOAuthTests { } @Test - func `maps enterprise O auth spend limit without session windows`() throws { + func `does not display spend limit 100x too high for enterprise O auth`() throws { let json = """ { "extra_usage": { "is_enabled": true, - "monthly_limit": 600, - "used_credits": 434.43, - "utilization": 72, - "currency": "USD" + "monthly_limit": 2000, + "used_credits": 763, + "utilization": 38.15, + "currency": "EUR" } } """ @@ -216,52 +216,55 @@ struct ClaudeOAuthTests { Data(json.utf8), subscriptionType: "enterprise") #expect(snap.loginMethod == "Claude Enterprise") - #expect(snap.primary.usedPercent == 72) + #expect(snap.primary.usedPercent == 38.15) #expect(snap.primaryWindowKind == .spendLimit) #expect(snap.primary.windowMinutes == nil) - #expect(snap.primary.resetDescription == "Spend limit: $434.43 / $600.00") + #expect(snap.primary.resetDescription == "Spend limit: €7.63 / €20.00") #expect(snap.secondary == nil) #expect(snap.providerCost?.period == "Spend limit") - #expect(snap.providerCost?.limit == 600) - #expect(snap.providerCost?.used == 434.43) + #expect(snap.providerCost?.currencyCode == "EUR") + #expect(snap.providerCost?.limit == 20) + #expect(snap.providerCost?.used == 7.63) let usage = ClaudeOAuthFetchStrategy._snapshotForTesting(from: snap) #expect(usage.primary == nil) #expect(usage.providerCost?.period == "Spend limit") - #expect(usage.providerCost?.used == 434.43) + #expect(usage.providerCost?.limit == 20) + #expect(usage.providerCost?.used == 7.63) } @Test - func `maps O auth spend limit without plan metadata as major units`() throws { + func `maps O auth spend limit without plan metadata from minor units`() throws { let json = """ { "extra_usage": { "is_enabled": true, - "monthly_limit": 600, - "used_credits": 434.43, - "utilization": 72, - "currency": "USD" + "monthly_limit": 2000, + "used_credits": 763, + "utilization": 38.15, + "currency": "EUR" } } """ let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) #expect(snap.loginMethod == nil) #expect(snap.primaryWindowKind == .spendLimit) - #expect(snap.primary.usedPercent == 72) - #expect(snap.primary.resetDescription == "Spend limit: $434.43 / $600.00") + #expect(snap.primary.usedPercent == 38.15) + #expect(snap.primary.resetDescription == "Spend limit: €7.63 / €20.00") #expect(snap.providerCost?.period == "Spend limit") - #expect(snap.providerCost?.limit == 600) - #expect(snap.providerCost?.used == 434.43) + #expect(snap.providerCost?.currencyCode == "EUR") + #expect(snap.providerCost?.limit == 20) + #expect(snap.providerCost?.used == 7.63) } @Test - func `maps large enterprise O auth spend limit as major units`() throws { + func `maps large enterprise O auth spend limit from minor units`() throws { let json = """ { "extra_usage": { "is_enabled": true, - "monthly_limit": 10000, - "used_credits": 1234.56, + "monthly_limit": 1000000, + "used_credits": 123456, "utilization": 12.3456, "currency": "USD" } @@ -339,6 +342,107 @@ struct ClaudeOAuthTests { #expect(err.localizedDescription.contains("HTTP 403")) } + @Test + func `O auth429 error gives actionable guidance without raw body`() { + let err = ClaudeOAuthFetchError.rateLimited(retryAfter: nil) + #expect(err.localizedDescription.contains("rate limited")) + #expect(err.localizedDescription.contains("claude logout && claude login")) + #expect(!err.localizedDescription.contains("rate_limit_error")) + } + + @Test + func `O auth429 usage fetch surfaces guidance without raw JSON`() async throws { + let fetcher = ClaudeUsageFetcher( + browserDetection: BrowserDetection(cacheTTL: 0), + environment: [:], + dataSource: .oauth, + oauthKeychainPromptCooldownEnabled: true) + + let loadCredsOverride: (@Sendable ( + [String: String], + Bool, + Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in + ClaudeOAuthCredentials( + accessToken: "rate-limited-token", + refreshToken: "refresh-token", + expiresAt: Date(timeIntervalSinceNow: 3600), + scopes: ["user:profile"], + rateLimitTier: nil) + } + let fetchOverride: (@Sendable (String) async throws -> OAuthUsageResponse)? = { _ in + throw ClaudeOAuthFetchError.rateLimited(retryAfter: nil) + } + + do { + _ = try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) { + try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue( + loadCredsOverride, + operation: { + try await fetcher.loadLatestUsage(model: "sonnet") + }) + } + Issue.record("Expected OAuth rate limit to fail with guidance") + } catch let error as ClaudeUsageError { + guard case let .oauthFailed(message) = error else { + Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)") + return + } + #expect(message.contains("rate limited")) + #expect(message.contains("claude logout && claude login")) + #expect(!message.contains("rate_limit_error")) + } catch { + Issue.record("Expected ClaudeUsageError, got \(error)") + } + } + + @Test + func `O auth usage rate limit gate blocks background retries until cooldown`() { + ClaudeOAuthUsageRateLimitGate.resetForTesting() + defer { ClaudeOAuthUsageRateLimitGate.resetForTesting() } + + let now = Date(timeIntervalSince1970: 1_700_000_000) + let retryAfter = now.addingTimeInterval(120) + + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now) == nil) + ClaudeOAuthUsageRateLimitGate.recordRateLimit(retryAfter: retryAfter, now: now) + + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now) == retryAfter) + #expect(ClaudeOAuthUsageRateLimitGate.blockedUntil(interaction: .background, now: now) == retryAfter) + #expect(ClaudeOAuthUsageRateLimitGate.blockedUntil(interaction: .userInitiated, now: now) == nil) + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now.addingTimeInterval(119)) != nil) + #expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now.addingTimeInterval(121)) == nil) + } + + @Test + func `O auth retry after parses seconds`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let url = try #require(URL(string: "https://api.anthropic.com/api/oauth/usage")) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 429, + httpVersion: "HTTP/1.1", + headerFields: ["Retry-After": "42"])) + + #expect( + ClaudeOAuthUsageFetcher._retryAfterDateForTesting(from: response, now: now) + == now.addingTimeInterval(42)) + } + + @Test + func `O auth retry after parses HTTP date`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let url = try #require(URL(string: "https://api.anthropic.com/api/oauth/usage")) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 429, + httpVersion: "HTTP/1.1", + headerFields: ["Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"])) + + #expect( + ClaudeOAuthUsageFetcher._retryAfterDateForTesting(from: response, now: now) + == Date(timeIntervalSince1970: 1_445_412_480)) + } + @Test func `oauth usage user agent uses claude code version`() { #expect( diff --git a/Tests/CodexBarTests/ClaudeWebRefreshResilienceTests.swift b/Tests/CodexBarTests/ClaudeWebRefreshResilienceTests.swift new file mode 100644 index 00000000..cc2cec5e --- /dev/null +++ b/Tests/CodexBarTests/ClaudeWebRefreshResilienceTests.swift @@ -0,0 +1,175 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +struct ClaudeWebRefreshResilienceTests { + @Test + func `web unauthorized respects failure gate while keeping prior Claude snapshot`() async throws { + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("missing-credentials.json") + + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let prior = Self.makePriorSnapshot() + let store = try await MainActor.run { + try Self.makeStore( + suite: "ClaudeWebRefreshResilienceTests-web-unauthorized", + prior: prior) + } + + await store.refreshProvider(.claude) + let firstResult = await MainActor.run { + ( + updatedAt: store.snapshot(for: .claude)?.updatedAt, + hasError: store.error(for: .claude) != nil) + } + + #expect(firstResult.updatedAt == prior.updatedAt) + #expect(!firstResult.hasError) + + await store.refreshProvider(.claude) + let secondResult = await MainActor.run { + ( + updatedAt: store.snapshot(for: .claude)?.updatedAt, + error: store.error(for: .claude)) + } + + #expect(secondResult.updatedAt == prior.updatedAt) + #expect(secondResult.error == ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription) + } + } + } + + @Test + func `web unauthorized without prior Claude snapshot still surfaces failure`() async throws { + try await ClaudeOAuthCredentialsStore.withIsolatedCredentialsFileTrackingForTesting { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent("missing-credentials.json") + + try await ClaudeOAuthCredentialsStore.withCredentialsURLOverrideForTesting(fileURL) { + let store = try await MainActor.run { + try Self.makeStore( + suite: "ClaudeWebRefreshResilienceTests-web-unauthorized-no-prior", + prior: nil) + } + + await store.refreshProvider(.claude) + let result = await MainActor.run { + ( + hasSnapshot: store.snapshot(for: .claude) != nil, + error: store.error(for: .claude)) + } + + #expect(!result.hasSnapshot) + #expect(result.error == ClaudeWebAPIFetcher.FetchError.unauthorized.localizedDescription) + } + } + } + + @MainActor + private static func makeStore(suite: String, prior: UsageSnapshot?) throws -> UsageStore { + let settings = self.makeSettingsStore(suite: suite) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.claudeUsageDataSource = .web + + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: provider == .claude) + } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + if let prior { + store._setSnapshotForTesting(prior, provider: .claude) + } + + let baseSpec = try #require(store.providerSpecs[.claude]) + let descriptor = ProviderDescriptor( + id: .claude, + metadata: baseSpec.descriptor.metadata, + branding: baseSpec.descriptor.branding, + tokenCost: baseSpec.descriptor.tokenCost, + fetchPlan: ProviderFetchPlan( + sourceModes: [.web], + pipeline: ProviderFetchPipeline { _ in [ClaudeWebUnauthorizedFetchStrategy()] }), + cli: baseSpec.descriptor.cli) + store.providerSpecs[.claude] = ProviderSpec( + style: baseSpec.style, + isEnabled: baseSpec.isEnabled, + descriptor: descriptor, + makeFetchContext: baseSpec.makeFetchContext) + return store + } + + private static func makePriorSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow( + usedPercent: 34, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_800_000_000), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + } + + @MainActor + private static func makeSettingsStore(suite: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + settings.providerDetectionCompleted = true + return settings + } +} + +private struct ClaudeWebUnauthorizedFetchStrategy: ProviderFetchStrategy { + let id = "test.claude-web-unauthorized" + let kind: ProviderFetchKind = .web + + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult { + throw ClaudeWebAPIFetcher.FetchError.unauthorized + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift index 32433229..b0d0241c 100644 --- a/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift +++ b/Tests/CodexBarTests/ClaudeWebUsageExtraWindowTests.swift @@ -17,7 +17,7 @@ struct ClaudeWebUsageExtraWindowTests { } @Test - func `parses claude web API omelette and cowork usage windows`() throws { + func `ignores merged claude web API omelette usage window`() throws { let json = """ { "five_hour": { "utilization": 9, "resets_at": "2025-12-23T16:00:00.000Z" }, @@ -27,8 +27,8 @@ struct ClaudeWebUsageExtraWindowTests { """ let data = Data(json.utf8) let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) - #expect(parsed.extraRateWindows.count == 2) - #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-design" })?.window.usedPercent == 26) + #expect(parsed.extraRateWindows.count == 1) + #expect(parsed.extraRateWindows.contains { $0.id == "claude-design" } == false) #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 11) } @@ -44,5 +44,6 @@ struct ClaudeWebUsageExtraWindowTests { let data = Data(json.utf8) let parsed = try ClaudeWebAPIFetcher._parseUsageResponseForTesting(data) #expect(parsed.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0) + #expect(parsed.extraRateWindows.contains { $0.id == "claude-design" } == false) } } diff --git a/Tests/CodexBarTests/CodexAccountReconciliationTests.swift b/Tests/CodexBarTests/CodexAccountReconciliationTests.swift index 438ae652..cf53f30e 100644 --- a/Tests/CodexBarTests/CodexAccountReconciliationTests.swift +++ b/Tests/CodexBarTests/CodexAccountReconciliationTests.swift @@ -124,6 +124,93 @@ struct CodexAccountReconciliationTests { #expect(projection.liveVisibleAccountID == "ambient@example.com") } + @Test + @MainActor + func `settings store can reuse short lived codex reconciliation snapshot`() throws { + let suite = "CodexAccountReconciliationTests-short-lived-cache" + let settings = try Self.makeSettings(suite: suite) + let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "cached@example.com", plan: "pro") + settings._test_codexReconciliationEnvironment = ["CODEX_HOME": ambientHome.path] + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: ambientHome) + } + + let first = settings.codexAccountReconciliationSnapshot + try FileManager.default.removeItem(at: ambientHome) + let cached = settings.codexAccountReconciliationSnapshot + settings.invalidateCodexAccountReconciliationSnapshotCache() + let refreshed = settings.codexAccountReconciliationSnapshot + + #expect(first.liveSystemAccount?.email == "cached@example.com") + #expect(cached.liveSystemAccount?.email == "cached@example.com") + #expect(refreshed.liveSystemAccount == nil) + } + + @Test + @MainActor + func `codex active source write invalidates short lived reconciliation snapshot`() throws { + let suite = "CodexAccountReconciliationTests-active-source-cache-invalidation" + let settings = try Self.makeSettings(suite: suite) + let ambientHome = FileManager.default.temporaryDirectory.appendingPathComponent( + UUID().uuidString, + isDirectory: true) + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "before@example.com", plan: "pro") + settings._test_codexReconciliationEnvironment = ["CODEX_HOME": ambientHome.path] + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_codexReconciliationEnvironment = nil + try? FileManager.default.removeItem(at: ambientHome) + } + + #expect(settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email == "before@example.com") + try Self.writeCodexAuthFile(homeURL: ambientHome, email: "after@example.com", plan: "pro") + settings.codexActiveSource = .liveSystem + + #expect(settings.codexAccountReconciliationSnapshot.liveSystemAccount?.email == "after@example.com") + } + + @Test + @MainActor + func `managed account changes invalidate short lived reconciliation snapshot`() throws { + let suite = "CodexAccountReconciliationTests-managed-change-cache-invalidation" + let settings = try Self.makeSettings(suite: suite) + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("codex-managed-store-\(UUID().uuidString).json") + try Self.writeManagedCodexStore( + ManagedCodexAccountSet(version: FileManagedCodexAccountStore.currentVersion, accounts: []), + to: storeURL) + let stored = ManagedCodexAccount( + id: UUID(), + email: "stored@example.com", + managedHomePath: "/tmp/stored-managed-home", + createdAt: 1, + updatedAt: 2, + lastAuthenticatedAt: 3) + settings._test_managedCodexAccountStoreURL = storeURL + settings.codexActiveSource = .managedAccount(id: stored.id) + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = 60 + defer { + SettingsStore.codexAccountReconciliationSnapshotCacheIntervalOverrideForTesting = nil + settings._test_managedCodexAccountStoreURL = nil + try? FileManager.default.removeItem(at: storeURL) + } + + #expect(settings.codexAccountReconciliationSnapshot.storedAccounts.isEmpty) + try Self.writeManagedCodexStore( + ManagedCodexAccountSet(version: FileManagedCodexAccountStore.currentVersion, accounts: [stored]), + to: storeURL) + settings.refreshCodexAccountReconciliationAfterManagedAccountsDidChange() + + #expect(settings.codexAccountReconciliationSnapshot.storedAccounts.map(\.id) == [stored.id]) + } + @Test @MainActor func `settings store home path override also keeps reconciliation hermetic`() throws { diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift index 3e8a519f..4316b0ad 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshDashboardCleanupTests.swift @@ -33,7 +33,7 @@ extension CodexAccountScopedRefreshTests { defer { store._test_codexCreditsLoaderOverride = nil } var observedTargetEmail: String? - store._test_openAIDashboardLoaderOverride = { accountEmail, _, _ in + store._test_openAIDashboardLoaderOverride = { accountEmail, _, _, _ in observedTargetEmail = accountEmail #expect(store.currentCodexOpenAIWebRefreshGuard().source == .liveSystem) #expect(store.currentCodexOpenAIWebRefreshGuard().identity == .unresolved) diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift index e2ee1d4a..8e0fc39c 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTestSupport.swift @@ -429,6 +429,17 @@ actor BlockingWidgetSnapshotSaver { } } + func waitUntilStartedWithin(count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let startedAt = ContinuousClock.now + while self.snapshots.count < count { + if startedAt.duration(to: .now) >= timeout { + return false + } + try? await Task.sleep(for: .milliseconds(50)) + } + return true + } + func startedCount() -> Int { self.snapshots.count } @@ -443,3 +454,26 @@ actor BlockingWidgetSnapshotSaver { self.snapshots } } + +actor RecordingWidgetSnapshotSaver { + private var snapshots: [WidgetSnapshot] = [] + + func save(_ snapshot: WidgetSnapshot) { + self.snapshots.append(snapshot) + } + + func waitUntilSavedWithin(count: Int, timeout: Duration = .seconds(5)) async -> Bool { + let startedAt = ContinuousClock.now + while self.snapshots.count < count { + if startedAt.duration(to: .now) >= timeout { + return false + } + try? await Task.sleep(for: .milliseconds(50)) + } + return true + } + + func savedSnapshots() -> [WidgetSnapshot] { + self.snapshots + } +} diff --git a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift index 3d64d423..f18726d2 100644 --- a/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift +++ b/Tests/CodexBarTests/CodexAccountScopedRefreshTests.swift @@ -338,7 +338,7 @@ struct CodexAccountScopedRefreshTests { let store = self.makeUsageStore(settings: settings) store.lastKnownLiveSystemCodexEmail = nil - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in self.dashboard(email: "seeded@example.com", creditsRemaining: 33, usedPercent: 12) } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -379,7 +379,7 @@ struct CodexAccountScopedRefreshTests { self.codexSnapshot(email: "trusted@example.com", usedPercent: 12), provider: .codex) store.lastSourceLabels[.codex] = "codex-cli" - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in self.dashboard(email: "trusted@example.com", creditsRemaining: 33, usedPercent: 12) } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -402,237 +402,173 @@ struct CodexAccountScopedRefreshTests { } @Test - func `no usable codex usage does not block weekly only dashboard backfill`() async { - let settings = self.makeSettingsStore( - suite: "CodexAccountScopedRefreshTests-no-usable-usage-weekly-dashboard-backfill") - settings.refreshFrequency = .manual - settings.codexCookieSource = .auto - settings._test_liveSystemCodexAccount = self.liveAccount( - email: "weekly@example.com", - identity: .providerAccount(id: "acct-weekly")) - - let store = self.makeUsageStore(settings: settings) - self.installFailingCodexProvider(on: store, error: UsageError.noRateLimitsFound) - - await store.refreshProvider(.codex, allowDisabled: true) - - #expect(store.snapshots[.codex] == nil) - - await store.applyOpenAIDashboard( - OpenAIDashboardSnapshot( - signedInEmail: "weekly@example.com", - codeReviewRemainingPercent: 88, - creditEvents: [], - dailyBreakdown: [], - usageBreakdown: [], - creditsPurchaseURL: nil, - primaryLimit: nil, - secondaryLimit: RateWindow( - usedPercent: 27, - windowMinutes: 10080, - resetsAt: Date(timeIntervalSince1970: 1_775_000_000), - resetDescription: "next week"), - creditsRemaining: 14, - accountPlan: "Pro", - updatedAt: Date(timeIntervalSince1970: 1_774_900_000)), - targetEmail: "weekly@example.com", - allowCodexUsageBackfill: true) - - #expect(store.openAIDashboard?.signedInEmail == "weekly@example.com") - #expect(store.snapshots[.codex]?.primary == nil) - #expect(store.snapshots[.codex]?.secondary?.usedPercent == 27) - #expect(store.snapshots[.codex]?.secondary?.windowMinutes == 10080) - #expect(store.lastSourceLabels[.codex] == "openai-web") + func `no usable codex usage does not block weekly only dashboard backfill`() throws { + let dashboard = OpenAIDashboardSnapshot( + signedInEmail: "weekly@example.com", + codeReviewRemainingPercent: 88, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: nil, + secondaryLimit: RateWindow( + usedPercent: 27, + windowMinutes: 10080, + resetsAt: Date(timeIntervalSince1970: 1_775_000_000), + resetDescription: "next week"), + creditsRemaining: 14, + accountPlan: "Pro", + updatedAt: Date(timeIntervalSince1970: 1_774_900_000)) + let decision = CodexDashboardAuthority.evaluate(CodexDashboardAuthorityInput( + sourceKind: .liveWeb, + proof: CodexDashboardOwnershipProofContext( + currentIdentity: .providerAccount(id: "acct-weekly"), + expectedScopedEmail: "weekly@example.com", + trustedCurrentUsageEmail: nil, + dashboardSignedInEmail: dashboard.signedInEmail, + knownOwners: [ + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-weekly"), + normalizedEmail: "weekly@example.com"), + ]), + routing: CodexDashboardRoutingHints( + targetEmail: "weekly@example.com", + lastKnownDashboardRoutingEmail: nil))) + let usage = try #require(dashboard.toUsageSnapshot( + provider: .codex, + accountEmail: "weekly@example.com")) + + #expect(decision.disposition == .attach) + #expect(decision.allowedEffects.contains(.usageBackfill)) + #expect(usage.primary == nil) + #expect(usage.secondary?.usedPercent == 27) + #expect(usage.secondary?.windowMinutes == 10080) + #expect(usage.accountEmail(for: .codex) == "weekly@example.com") + #expect(usage.identity?.loginMethod == "Pro") } @Test - func `dashboard display only keeps dashboard visible and clears dashboard derived data`() async throws { - let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-dashboard-display-only-cleanup") - let managedHome = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: managedHome) } - try Self.writeCodexAuthFile( - homeURL: managedHome, - email: "shared@example.com", - plan: "pro", - accountId: "acct-managed") - - let managedAccount = ManagedCodexAccount( - id: UUID(), - email: "shared@example.com", - managedHomePath: managedHome.path, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1) - let managedStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) - defer { - settings._test_managedCodexAccountStoreURL = nil - try? FileManager.default.removeItem(at: managedStoreURL) - OpenAIDashboardCacheStore.clear() - } - - settings.refreshFrequency = .manual - settings.codexCookieSource = .auto - settings._test_managedCodexAccountStoreURL = managedStoreURL - settings._test_liveSystemCodexAccount = self.liveAccount( - email: "shared@example.com", - identity: .emailOnly(normalizedEmail: "shared@example.com")) - settings.codexActiveSource = .liveSystem - - let store = self.makeUsageStore(settings: settings) - store._setSnapshotForTesting(self.codexSnapshot(email: "shared@example.com", usedPercent: 20), provider: .codex) - store.lastSourceLabels[.codex] = "openai-web" - let staleCredits = self.credits(remaining: 20) - store.credits = staleCredits - store.lastCreditsSnapshot = staleCredits - store.lastCreditsSnapshotAccountKey = "shared@example.com" - store.lastCreditsSource = .dashboardWeb - OpenAIDashboardCacheStore.save(OpenAIDashboardCache( - accountEmail: "shared@example.com", - snapshot: self.dashboard(email: "shared@example.com", creditsRemaining: 20, usedPercent: 20))) - - await store.applyOpenAIDashboard( - self.dashboard(email: "shared@example.com", creditsRemaining: 9, usedPercent: 35), - targetEmail: "shared@example.com") - - #expect(store.openAIDashboard?.signedInEmail == "shared@example.com") - #expect(store.lastOpenAIDashboardSnapshot?.signedInEmail == "shared@example.com") - #expect(store.snapshots[.codex] == nil) - #expect(store.lastSourceLabels[.codex] == nil) - #expect(store.credits == nil) - #expect(store.lastCreditsSource == .none) - #expect(OpenAIDashboardCacheStore.load() == nil) + func `dashboard display only keeps dashboard visible and clears dashboard derived data`() { + let decision = CodexDashboardAuthority.evaluate(CodexDashboardAuthorityInput( + sourceKind: .liveWeb, + proof: CodexDashboardOwnershipProofContext( + currentIdentity: .emailOnly(normalizedEmail: "shared@example.com"), + expectedScopedEmail: nil, + trustedCurrentUsageEmail: nil, + dashboardSignedInEmail: "shared@example.com", + knownOwners: [ + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-managed"), + normalizedEmail: "shared@example.com"), + CodexDashboardKnownOwnerCandidate( + identity: .emailOnly(normalizedEmail: "shared@example.com"), + normalizedEmail: "shared@example.com"), + ]), + routing: CodexDashboardRoutingHints( + targetEmail: "shared@example.com", + lastKnownDashboardRoutingEmail: nil))) + + #expect(decision.disposition == .displayOnly) + #expect(decision.reason == .sameEmailAmbiguity(email: "shared@example.com")) + #expect(decision.allowedEffects.isEmpty) + #expect(decision.cleanup == Set(CodexDashboardCleanup.allCases)) } @Test - func `dashboard downgrade from real attach to display only retires owned state immediately`() async throws { - OpenAIDashboardCacheStore.clear() - defer { OpenAIDashboardCacheStore.clear() } - - let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-dashboard-downgrade") - let managedHome = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - defer { try? FileManager.default.removeItem(at: managedHome) } - try Self.writeCodexAuthFile( - homeURL: managedHome, - email: "shared@example.com", - plan: "pro", - accountId: "acct-managed") - - let managedAccount = ManagedCodexAccount( - id: UUID(), - email: "shared@example.com", - managedHomePath: managedHome.path, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1) - let managedStoreURL = try self.makeManagedAccountStoreURL(accounts: [managedAccount]) - defer { - settings._test_managedCodexAccountStoreURL = nil - try? FileManager.default.removeItem(at: managedStoreURL) - } - - settings.refreshFrequency = .manual - settings.codexCookieSource = .auto - settings._test_liveSystemCodexAccount = self.liveAccount( - email: "shared@example.com", - identity: .emailOnly(normalizedEmail: "shared@example.com")) - settings.codexActiveSource = .liveSystem - - let store = self.makeUsageStore(settings: settings) - await store.applyOpenAIDashboard( - self.dashboard(email: "shared@example.com", creditsRemaining: 20, usedPercent: 20), - targetEmail: "shared@example.com") - - #expect(store.openAIDashboard?.signedInEmail == "shared@example.com") - #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == "shared@example.com") - #expect(store.lastSourceLabels[.codex] == "openai-web") - #expect(store.credits?.remaining == 20) - #expect(store.lastCreditsSource == .dashboardWeb) - #expect(OpenAIDashboardCacheStore.load()?.accountEmail == "shared@example.com") - - settings._test_managedCodexAccountStoreURL = managedStoreURL - - await store.applyOpenAIDashboard( - self.dashboard(email: "shared@example.com", creditsRemaining: 9, usedPercent: 35), - targetEmail: "shared@example.com") - - #expect(store.openAIDashboard?.signedInEmail == "shared@example.com") - #expect(store.lastOpenAIDashboardSnapshot?.signedInEmail == "shared@example.com") - #expect(store.snapshots[.codex] == nil) - #expect(store.lastSourceLabels[.codex] == nil) - #expect(store.credits == nil) - #expect(store.lastCreditsSource == .none) - #expect(OpenAIDashboardCacheStore.load() == nil) + func `dashboard downgrade from real attach to display only retires owned state immediately`() { + let attached = CodexDashboardAuthority.evaluate(CodexDashboardAuthorityInput( + sourceKind: .liveWeb, + proof: CodexDashboardOwnershipProofContext( + currentIdentity: .emailOnly(normalizedEmail: "shared@example.com"), + expectedScopedEmail: nil, + trustedCurrentUsageEmail: nil, + dashboardSignedInEmail: "shared@example.com", + knownOwners: [ + CodexDashboardKnownOwnerCandidate( + identity: .emailOnly(normalizedEmail: "shared@example.com"), + normalizedEmail: "shared@example.com"), + ]), + routing: CodexDashboardRoutingHints( + targetEmail: "shared@example.com", + lastKnownDashboardRoutingEmail: nil))) + let downgraded = CodexDashboardAuthority.evaluate(CodexDashboardAuthorityInput( + sourceKind: .liveWeb, + proof: CodexDashboardOwnershipProofContext( + currentIdentity: .emailOnly(normalizedEmail: "shared@example.com"), + expectedScopedEmail: nil, + trustedCurrentUsageEmail: nil, + dashboardSignedInEmail: "shared@example.com", + knownOwners: [ + CodexDashboardKnownOwnerCandidate( + identity: .emailOnly(normalizedEmail: "shared@example.com"), + normalizedEmail: "shared@example.com"), + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-managed"), + normalizedEmail: "shared@example.com"), + ]), + routing: CodexDashboardRoutingHints( + targetEmail: "shared@example.com", + lastKnownDashboardRoutingEmail: nil))) + + #expect(attached.disposition == .attach) + #expect(attached.cleanup.isEmpty) + #expect(downgraded.disposition == .displayOnly) + #expect(downgraded.reason == .sameEmailAmbiguity(email: "shared@example.com")) + #expect(downgraded.allowedEffects.isEmpty) + #expect(downgraded.cleanup == Set(CodexDashboardCleanup.allCases)) } @Test - func `dashboard refresh rejects stale completion during live account reconciliation lag`() async { - let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-dashboard-reject-stale-live-lag") - let isolatedHome = FileManager.default.temporaryDirectory - .appendingPathComponent("codex-openai-web-stale-live-lag-\(UUID().uuidString)", isDirectory: true) - try? FileManager.default.createDirectory(at: isolatedHome, withIntermediateDirectories: true) - settings.refreshFrequency = .manual - settings.codexCookieSource = .auto - settings._test_codexReconciliationEnvironment = ["CODEX_HOME": isolatedHome.path] - settings.codexActiveSource = .liveSystem - defer { - settings._test_codexReconciliationEnvironment = nil - try? FileManager.default.removeItem(at: isolatedHome) - } - - let store = self.makeUsageStore(settings: settings) - store._setSnapshotForTesting(self.codexSnapshot(email: "alpha@example.com", usedPercent: 12), provider: .codex) - - let expectedGuard = store.currentCodexOpenAIWebRefreshGuard() - #expect(expectedGuard.accountKey == nil) - - store._setSnapshotForTesting(self.codexSnapshot(email: "beta@example.com", usedPercent: 18), provider: .codex) - - await store.applyOpenAIDashboard( - self.dashboard(email: "alpha@example.com", creditsRemaining: 40, usedPercent: 20), - targetEmail: nil, - expectedGuard: expectedGuard, - allowCodexUsageBackfill: true) - - #expect(store.openAIDashboard == nil) - #expect(store.credits == nil) - #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == "beta@example.com") + func `dashboard refresh rejects stale completion during live account reconciliation lag`() { + let decision = CodexDashboardAuthority.evaluate(CodexDashboardAuthorityInput( + sourceKind: .liveWeb, + proof: CodexDashboardOwnershipProofContext( + currentIdentity: .unresolved, + expectedScopedEmail: nil, + trustedCurrentUsageEmail: "beta@example.com", + dashboardSignedInEmail: "alpha@example.com", + knownOwners: []), + routing: CodexDashboardRoutingHints( + targetEmail: nil, + lastKnownDashboardRoutingEmail: nil))) + + #expect(decision.disposition == .failClosed) + #expect(decision.reason == .wrongEmail(expected: "beta@example.com", actual: "alpha@example.com")) + #expect(decision.allowedEffects.isEmpty) + #expect(decision.cleanup == Set(CodexDashboardCleanup.allCases)) } @Test - func `default dashboard refresh path discards stale completion after account switch`() async { + func `default dashboard refresh path discards stale completion after account switch`() { let settings = self.makeSettingsStore(suite: "CodexAccountScopedRefreshTests-dashboard-guard") - settings.refreshFrequency = .manual - settings.openAIWebAccessEnabled = true - settings.codexCookieSource = .auto + settings.codexActiveSource = .liveSystem settings._test_liveSystemCodexAccount = self.liveAccount(email: "alpha@example.com") let store = self.makeUsageStore(settings: settings) - self.installImmediateCodexProvider( - on: store, - snapshot: self.codexSnapshot(email: "alpha@example.com", usedPercent: 18)) - let dashboardBlocker = BlockingOpenAIDashboardLoader() - store._test_openAIDashboardLoaderOverride = { _, _, _ in - try await dashboardBlocker.awaitResult() - } - defer { store._test_openAIDashboardLoaderOverride = nil } - - let refreshTask = Task { await store.refresh() } - await dashboardBlocker.waitUntilStarted() + let expectedGuard = store.currentCodexOpenAIWebRefreshGuard() + #expect(expectedGuard.identity == .emailOnly(normalizedEmail: "alpha@example.com")) settings._test_liveSystemCodexAccount = self.liveAccount(email: "beta@example.com") - store._setSnapshotForTesting(self.codexSnapshot(email: "beta@example.com", usedPercent: 7), provider: .codex) - store.openAIDashboard = nil - store.credits = nil - - await dashboardBlocker.resume(with: .success( - self.dashboard(email: "alpha@example.com", creditsRemaining: 44, usedPercent: 21))) - await refreshTask.value - - #expect(store.openAIDashboard == nil) - #expect(store.credits == nil) - #expect(store.snapshots[.codex]?.accountEmail(for: .codex) == "beta@example.com") + #expect(store.shouldApplyOpenAIDashboardRefreshGuard( + expectedGuard: expectedGuard, + routingTargetEmail: "alpha@example.com") == false) + + let decision = CodexDashboardAuthority.evaluate(CodexDashboardAuthorityInput( + sourceKind: .liveWeb, + proof: CodexDashboardOwnershipProofContext( + currentIdentity: .emailOnly(normalizedEmail: "beta@example.com"), + expectedScopedEmail: "beta@example.com", + trustedCurrentUsageEmail: nil, + dashboardSignedInEmail: "alpha@example.com", + knownOwners: []), + routing: CodexDashboardRoutingHints( + targetEmail: "alpha@example.com", + lastKnownDashboardRoutingEmail: nil))) + + #expect(decision.disposition == .failClosed) + #expect(decision.reason == .wrongEmail(expected: "beta@example.com", actual: "alpha@example.com")) + #expect(decision.allowedEffects.isEmpty) + #expect(decision.cleanup == Set(CodexDashboardCleanup.allCases)) } @Test @@ -871,10 +807,12 @@ struct CodexAccountScopedRefreshTests { await blocker.waitUntilStarted() await blocker.resume(with: .success(self.codexSnapshot(email: "alpha@example.com", usedPercent: 12))) await refreshTask.value + #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "alpha@example.com") + + await store.creditsRefreshTask?.value #expect(store.credits?.remaining == 55) #expect(store.lastCreditsSource == .api) - #expect(store.lastCodexAccountScopedRefreshGuard?.accountKey == "alpha@example.com") } @Test diff --git a/Tests/CodexBarTests/CodexAccountsSettingsSectionTests.swift b/Tests/CodexBarTests/CodexAccountsSettingsSectionTests.swift index 5b5b57d7..7ce1ff29 100644 --- a/Tests/CodexBarTests/CodexAccountsSettingsSectionTests.swift +++ b/Tests/CodexBarTests/CodexAccountsSettingsSectionTests.swift @@ -276,6 +276,17 @@ struct CodexAccountsSettingsSectionTests { #expect(state.activeVisibleAccountID == "managed@example.com") } + @Test + func `managed codex login failure message includes codex login output`() { + let error = ManagedCodexAccountServiceError.loginFailed(CodexLoginRunner.Result( + outcome: .failed(status: 2), + output: "Browser selected the existing ChatGPT account")) + + #expect(error.userFacingMessage.contains("codex --version")) + #expect(error.userFacingMessage.contains("codex login output:")) + #expect(error.userFacingMessage.contains("Browser selected the existing ChatGPT account")) + } + private static func makeManagedCoordinator( settings: SettingsStore, email: String) diff --git a/Tests/CodexBarTests/CodexAdditionalRateLimitsTests.swift b/Tests/CodexBarTests/CodexAdditionalRateLimitsTests.swift new file mode 100644 index 00000000..3809cb2c --- /dev/null +++ b/Tests/CodexBarTests/CodexAdditionalRateLimitsTests.swift @@ -0,0 +1,311 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CodexAdditionalRateLimitsTests { + @Test + func `maps additional spark limit into a named extra rate window`() throws { + let json = """ + { + "plan_type": "pro", + "rate_limit": { + "primary_window": { + "used_percent": 22, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 43, + "reset_at": 1767407914, + "limit_window_seconds": 604800 + } + }, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "gpt_5_3_codex_spark", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 30, + "reset_at": 1766948068, + "limit_window_seconds": 18000, + "reset_after_seconds": 12345 + }, + "secondary_window": { + "used_percent": 100, + "reset_at": 1767407914, + "limit_window_seconds": 604800, + "reset_after_seconds": 56789 + } + } + } + ] + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let mapped = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) + let snapshot = try #require(mapped) + // Primary/weekly behavior is unchanged. + #expect(snapshot.primary?.usedPercent == 22) + #expect(snapshot.secondary?.usedPercent == 43) + // Spark surfaces as distinct 5-hour and weekly extra windows. + let extras = try #require(snapshot.extraRateWindows) + #expect(extras.count == 2) + let spark = try #require(extras.first) + #expect(spark.id == "codex-spark") + #expect(spark.title == "Codex Spark 5-hour") + #expect(spark.window.usedPercent == 30) + #expect(spark.window.windowMinutes == 300) + #expect(spark.window.resetsAt != nil) + let weekly = try #require(extras.last) + #expect(weekly.id == "codex-spark-weekly") + #expect(weekly.title == "Codex Spark Weekly") + #expect(weekly.window.usedPercent == 100) + #expect(weekly.window.windowMinutes == 10080) + #expect(weekly.window.resetsAt != nil) + } + + @Test + func `keeps valid spark window when an additional limit sibling is malformed`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 }, + "secondary_window": { "used_percent": 43, "reset_at": 1767407914, "limit_window_seconds": 604800 } + }, + "additional_rate_limits": [ + "garbage-not-an-object", + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "gpt_5_3_codex_spark", + "rate_limit": { + "primary_window": { "used_percent": 30, "reset_at": 1766948068, "limit_window_seconds": 18000 } + } + }, + 42 + ] + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let mapped = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) + let snapshot = try #require(mapped) + // Valid primary/weekly fields do not regress. + #expect(snapshot.primary?.usedPercent == 22) + #expect(snapshot.secondary?.usedPercent == 43) + // The malformed siblings are skipped, but the valid Spark entry survives. + let extras = try #require(snapshot.extraRateWindows) + #expect(extras.count == 1) + #expect(extras.first?.id == "codex-spark") + #expect(extras.first?.window.usedPercent == 30) + } + + @Test + func `keeps primary usage when every additional limit element is malformed`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 } + }, + "additional_rate_limits": ["garbage", 1, true] + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let snapshot = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) + #expect(snapshot?.primary?.usedPercent == 22) + #expect(snapshot?.extraRateWindows == nil) + } + + @Test + func `omits extra rate windows when additional limits are absent`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": 22, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + } + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let snapshot = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) + #expect(snapshot?.primary?.usedPercent == 22) + #expect(snapshot?.extraRateWindows == nil) + } + + @Test + func `tolerates malformed additional limits while keeping primary window`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": 22, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + }, + "additional_rate_limits": "unexpected" + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let snapshot = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) + #expect(snapshot?.primary?.usedPercent == 22) + #expect(snapshot?.extraRateWindows == nil) + } + + @Test + func `skips additional limits without a usable window`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { + "used_percent": 22, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + }, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "gpt_5_3_codex_spark", + "rate_limit": null + } + ] + } + """ + let creds = CodexOAuthCredentials( + accessToken: "access", + refreshToken: "refresh", + idToken: nil, + accountId: nil, + lastRefresh: Date()) + let snapshot = try CodexOAuthFetchStrategy._mapUsageForTesting(Data(json.utf8), credentials: creds) + #expect(snapshot?.primary?.usedPercent == 22) + #expect(snapshot?.extraRateWindows == nil) + } + + @Test + func `maps non spark additional limit using a slugged id and api label`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let json = """ + [ + { + "limit_name": "GPT-5.3-Codex-Mini", + "metered_feature": "gpt_5_3_codex_mini", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 12, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + } + }, + { + "limit_name": "GPT-5.3-Codex-Mini", + "metered_feature": "gpt_5_3_codex_mini", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 99, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + } + } + ] + """ + let entries = try JSONDecoder().decode([CodexUsageResponse.AdditionalRateLimit].self, from: Data(json.utf8)) + let windows = CodexAdditionalRateLimitMapper.extraRateWindows(from: entries, now: now) + // Duplicate ids collapse to the first occurrence. + #expect(windows.count == 1) + let window = try #require(windows.first) + #expect(window.id == "codex-gpt-5-3-codex-mini") + #expect(window.title == "GPT-5.3-Codex-Mini") + #expect(window.window.usedPercent == 12) + } + + @Test + func `dedupes split spark entries by window kind`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let json = """ + [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "gpt_5_3_codex_spark", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 20, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + } + }, + { + "limit_name": "GPT-5.3-Codex-Spark Weekly", + "metered_feature": "gpt_5_3_codex_spark", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 80, + "reset_at": 1767407914, + "limit_window_seconds": 604800 + } + } + }, + { + "limit_name": "GPT-5.3-Codex-Spark Duplicate", + "metered_feature": "gpt_5_3_codex_spark", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 99, + "reset_at": 1766948068, + "limit_window_seconds": 18000 + } + } + } + ] + """ + let entries = try JSONDecoder().decode([CodexUsageResponse.AdditionalRateLimit].self, from: Data(json.utf8)) + let windows = CodexAdditionalRateLimitMapper.extraRateWindows(from: entries, now: now) + + #expect(windows.map(\.id) == ["codex-spark", "codex-spark-weekly"]) + #expect(windows.first?.window.usedPercent == 20) + #expect(windows.last?.window.usedPercent == 80) + } +} diff --git a/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift new file mode 100644 index 00000000..720cf744 --- /dev/null +++ b/Tests/CodexBarTests/CodexBackgroundRefreshCoalescingTests.swift @@ -0,0 +1,359 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@Suite(.serialized) +@MainActor +struct CodexBackgroundRefreshCoalescingTests { + @Test + func `rapid regular refreshes coalesce concurrent Codex credits fetches`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-credits-coalescing") + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let blocker = BlockingCreditsLoader() + let firstCompletion = RefreshCompletionProbe() + let secondCompletion = RefreshCompletionProbe() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + try await blocker.awaitResult() + } + defer { store._test_codexCreditsLoaderOverride = nil } + + let firstRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + await firstCompletion.markCompleted() + } + await blocker.waitUntilStarted(count: 1) + #expect(await firstCompletion.waitUntilCompleted() == true) + + let secondRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + await secondCompletion.markCompleted() + } + + #expect(await secondCompletion.waitUntilCompleted() == true) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) + + await firstRefreshTask.value + await secondRefreshTask.value + } + + @Test + func `regular credits refresh reschedules when Codex account changes`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-credits-account-switch") + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = false + let alphaAccount = try Self.makeManagedAccount(email: "alpha@example.com") + let betaAccount = try Self.makeManagedAccount(email: "beta@example.com") + defer { + try? FileManager.default.removeItem(atPath: alphaAccount.managedHomePath) + try? FileManager.default.removeItem(atPath: betaAccount.managedHomePath) + } + settings._test_activeManagedCodexAccount = alphaAccount + settings.codexActiveSource = .managedAccount(id: alphaAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + let store = self.makeStore(settings: settings) + let blocker = BlockingCreditsLoader() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + try await blocker.awaitResult() + } + defer { store._test_codexCreditsLoaderOverride = nil } + + let alphaRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + } + await blocker.waitUntilStarted(count: 1) + + settings._test_activeManagedCodexAccount = betaAccount + settings.codexActiveSource = .managedAccount(id: betaAccount.id) + let betaRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + } + await blocker.waitUntilStarted(count: 2) + + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 10, events: [], updatedAt: Date()))) + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) + + await alphaRefreshTask.value + await betaRefreshTask.value + await store.creditsRefreshTask?.value + + #expect(await blocker.startedCount() == 2) + #expect(store.lastCreditsSnapshotAccountKey == "beta@example.com") + #expect(store.credits?.remaining == 25) + } + + @Test + func `force refresh cancels stale background Codex credits fetch`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-credits-force-cancels-background") + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let blocker = BlockingCreditsLoader() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + try await blocker.awaitResult() + } + defer { store._test_codexCreditsLoaderOverride = nil } + + let regularRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + } + await blocker.waitUntilStarted(count: 1) + + let forceRefreshTask = Task { + await store.refresh(forceTokenUsage: true) + } + await blocker.waitUntilStarted(count: 2) + + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 10, events: [], updatedAt: Date()))) + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) + + await regularRefreshTask.value + await forceRefreshTask.value + + #expect(await blocker.startedCount() == 2) + #expect(store.credits?.remaining == 25) + } + + @Test + func `rapid regular refreshes coalesce concurrent OpenAI dashboard fetches`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-dashboard-coalescing") + settings.statusChecksEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let blocker = BlockingManagedOpenAIDashboardLoader() + let firstCompletion = RefreshCompletionProbe() + let secondCompletion = RefreshCompletionProbe() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let firstRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + await firstCompletion.markCompleted() + } + await blocker.waitUntilStarted(count: 1) + #expect(await firstCompletion.waitUntilCompleted() == true) + + let secondRefreshTask = Task { + await store.refresh(forceTokenUsage: false) + await secondCompletion.markCompleted() + } + + #expect(await secondCompletion.waitUntilCompleted() == true) + #expect(await blocker.startedCount() == 1) + + let backgroundTask = try #require(store.openAIDashboardBackgroundRefreshTask) + await blocker.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + + await firstRefreshTask.value + await secondRefreshTask.value + await backgroundTask.value + + #expect(store.openAIDashboard?.creditsRemaining == 25) + } + + @Test + func `cancelled background dashboard import does not publish stale account status`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexBackgroundRefreshCoalescingTests-dashboard-cancelled-import") + settings.statusChecksEnabled = false + let managedAccount = try Self.installManagedAccount( + email: "managed@example.com", + settings: settings) + defer { try? FileManager.default.removeItem(atPath: managedAccount.managedHomePath) } + + let store = self.makeStore(settings: settings) + let importBlocker = BlockingOpenAIDashboardCookieImport() + store._test_openAIDashboardCookieImportOverride = { _, _, _, _, _ in + try await importBlocker.awaitResult() + } + defer { store._test_openAIDashboardCookieImportOverride = nil } + + let importTask = Task { @MainActor in + await store.importOpenAIDashboardCookiesIfNeeded( + targetEmail: managedAccount.email, + force: true) + } + await importBlocker.waitUntilStarted() + importTask.cancel() + await importBlocker.resumeNext(with: .failure( + OpenAIDashboardBrowserCookieImporter.ImportError.noMatchingAccount( + found: [.init(sourceLabel: "Chrome", email: "other@example.com")]))) + + let imported = await importTask.value + #expect(imported == nil) + #expect(store.openAIDashboard == nil) + #expect(store.openAIDashboardCookieImportStatus == nil) + #expect(store.openAIDashboardRequiresLogin == false) + } + + private func makeSettingsStore(suite: String) throws -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + defaults.set(true, forKey: "providerDetectionCompleted") + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + let codexMetadata = try #require(ProviderDescriptorRegistry.metadata[.codex]) + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + settings.providerDetectionCompleted = true + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + return settings + } + + private func makeStore(settings: SettingsStore) -> UsageStore { + UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + } + + private static func installManagedAccount( + email: String, + settings: SettingsStore) throws -> ManagedCodexAccount + { + let account = try Self.makeManagedAccount(email: email) + settings._test_activeManagedCodexAccount = account + settings.codexActiveSource = .managedAccount(id: account.id) + return account + } + + private static func makeManagedAccount(email: String) throws -> ManagedCodexAccount { + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: email, + plan: "Pro") + return ManagedCodexAccount( + id: UUID(), + email: email, + managedHomePath: managedHomeURL.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + } + + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + let tokens: [String: Any] = [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeJWT(email: email, plan: plan), + ] + let data = try JSONSerialization.data(withJSONObject: ["tokens": tokens], options: [.sortedKeys]) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static func fakeJWT(email: String, plan: String) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + let payload = (try? JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + "https://api.openai.com/auth": [ + "chatgpt_plan_type": plan, + ], + ])) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } +} + +private actor BlockingOpenAIDashboardCookieImport { + private var continuations: [ + CheckedContinuation, Never> + ] = [] + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var started = 0 + + func awaitResult() async throws -> OpenAIDashboardBrowserCookieImporter.ImportResult { + let result = await withCheckedContinuation { continuation in + self.continuations.append(continuation) + self.started += 1 + self.resumeReadyStartWaiters() + } + return try result.get() + } + + func waitUntilStarted(count: Int = 1) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func resumeNext(with result: Result) { + guard !self.continuations.isEmpty else { return } + let continuation = self.continuations.removeFirst() + continuation.resume(returning: result) + } + + private func resumeReadyStartWaiters() { + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in self.startWaiters { + if self.started >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + self.startWaiters = remaining + } +} diff --git a/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift b/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift index 03ffc9a4..363341ff 100644 --- a/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift +++ b/Tests/CodexBarTests/CodexBarConfigMigratorTests.swift @@ -57,6 +57,42 @@ struct CodexBarConfigMigratorTests { #expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == true) } + @Test + func `legacy stores are kept when migrated config save fails`() throws { + let suite = "CodexBarConfigMigratorTests-save-failure-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-tests", isDirectory: true) + .appendingPathComponent(suite, isDirectory: true) + try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let blockedDirectory = base.appendingPathComponent("blocked") + try Data("not a directory".utf8).write(to: blockedDirectory) + + let secrets = CountingLegacySecretStore(token: "legacy-token") + let accountStore = CountingTokenAccountStore() + let stores = Self.legacyStores(secrets: secrets, accountStore: accountStore) + let configStore = CodexBarConfigStore( + fileURL: blockedDirectory.appendingPathComponent("config.json")) + + _ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores) + + #expect(secrets.clearAttempts == 0) + #expect(try secrets.loadToken() == "legacy-token") + #expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == false) + + try FileManager.default.removeItem(at: blockedDirectory) + _ = CodexBarConfigMigrator.loadOrMigrate(configStore: configStore, userDefaults: defaults, stores: stores) + + #expect(secrets.clearAttempts > 0) + #expect(try secrets.loadToken() == nil) + #expect(defaults.bool(forKey: Self.legacyMigrationCompletedKey) == true) + } + private static let legacyMigrationCompletedKey = "codexbar.legacySecretsMigrationCompleted" private static func legacyStores( diff --git a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift index a496dfc0..8d1754bc 100644 --- a/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift +++ b/Tests/CodexBarTests/CodexBarWidgetProviderTests.swift @@ -129,6 +129,27 @@ struct CodexBarWidgetProviderTests { #expect(rows == [WidgetUsageRow(id: "weekly", title: "Weekly", percentLeft: 75)]) } + @Test + func `legacy widget usage rows include tertiary slot when supported`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let entry = WidgetSnapshot.ProviderEntry( + provider: .antigravity, + updatedAt: now, + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + creditsRemaining: nil, + codeReviewRemainingPercent: nil, + tokenUsage: nil, + dailyUsage: []) + + let rows = WidgetUsageRow.rows(for: entry) + + #expect(rows.map(\.id) == ["primary", "secondary", "tertiary"]) + #expect(rows.map(\.title) == ["Claude", "Gemini Pro", "Gemini Flash"]) + #expect(rows.compactMap(\.percentLeft) == [90, 80, 70]) + } + @Test func `widget configuration intents default to codex and credits`() { let providerIntent = ProviderSelectionIntent() diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift index 213d965f..a5ed210f 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebRefreshTests.swift @@ -6,12 +6,335 @@ import Testing @Suite(.serialized) @MainActor struct CodexManagedOpenAIWebRefreshTests { + @Test + func `regular refresh does not await OpenAI web scrape`() async throws { + let settings = try self + .makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-regular-refresh-nonblocking") + settings.statusChecksEnabled = false + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHomeURL) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHomeURL.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + settings.openAIWebAccessEnabled = false + defer { settings._test_activeManagedCodexAccount = nil } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let blocker = BlockingManagedOpenAIDashboardLoader() + let completion = RefreshCompletionProbe() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + + await store.refresh(forceTokenUsage: false) + settings.openAIWebAccessEnabled = true + + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in + OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Chrome", + cookieCount: 2, + signedInEmail: targetEmail, + matchesCodexEmail: true) + } + defer { store._test_openAIDashboardCookieImportOverride = nil } + + let refreshTask = Task { + await store.refresh(forceTokenUsage: false) + await completion.markCompleted() + } + + let didStart = await blocker.waitUntilStartedWithin(count: 1, timeout: .seconds(60)) + #expect(didStart == true) + if !didStart { + refreshTask.cancel() + return + } + + let completed = await completion.waitUntilCompleted(timeout: .seconds(2)) + #expect(completed == true) + if !completed { + refreshTask.cancel() + await blocker.resumeNext(with: .failure(ManagedDashboardTestError.networkTimeout)) + return + } + await refreshTask.value + + let backgroundTask = try #require(store.openAIDashboardBackgroundRefreshTask) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + + await backgroundTask.value + } + + @Test + func `regular refresh does not await Codex credits fetch`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexManagedOpenAIWebRefreshTests-regular-refresh-nonblocking-credits") + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = false + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHomeURL) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHomeURL.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let blocker = BlockingCreditsLoader() + let completion = RefreshCompletionProbe() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + try await blocker.awaitResult() + } + defer { store._test_codexCreditsLoaderOverride = nil } + + let refreshTask = Task { + await store.refresh(forceTokenUsage: false) + await completion.markCompleted() + } + + await blocker.waitUntilStarted(count: 1) + + #expect(await blocker.startedCount() == 1) + #expect(await completion.isCompleted == true) + + await blocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) + + await refreshTask.value + } + + @Test + func `background credits refresh persists updated widget snapshot after refresh returns`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexManagedOpenAIWebRefreshTests-widget-background-credits") + settings.statusChecksEnabled = false + settings.openAIWebAccessEnabled = false + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHomeURL) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHomeURL.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store.snapshots[.codex] = Self.codexSnapshot(email: managedAccount.email, usedPercent: 18) + + let creditsBlocker = BlockingCreditsLoader() + let saver = BlockingWidgetSnapshotSaver() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + try await creditsBlocker.awaitResult() + } + defer { store._test_codexCreditsLoaderOverride = nil } + store._test_widgetSnapshotSaveOverride = { snapshot in + await saver.save(snapshot) + } + defer { store._test_widgetSnapshotSaveOverride = nil } + + let refreshTask = Task { + await store.refresh(forceTokenUsage: false) + } + + await refreshTask.value + await saver.waitUntilStarted(count: 1) + + let firstSnapshots = await saver.savedSnapshots() + let firstCodexEntry = try #require(firstSnapshots.first?.entries.first { $0.provider == .codex }) + #expect(firstCodexEntry.creditsRemaining == nil) + + await saver.resumeNext() + let backgroundTask = try #require(store.creditsRefreshTask) + await creditsBlocker.waitUntilStarted(count: 1) + await creditsBlocker.resumeNext(with: .success(CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()))) + await backgroundTask.value + await saver.waitUntilStarted(count: 2) + + #expect(await saver.startedCount() == 2) + let secondSnapshots = await saver.savedSnapshots() + let secondCodexEntry = try #require(secondSnapshots.last?.entries.first { $0.provider == .codex }) + #expect(secondCodexEntry.creditsRemaining == 25) + + await saver.resumeNext() + await store.widgetSnapshotPersistTask?.value + } + + @Test + func `background dashboard refresh persists updated widget snapshot after refresh returns`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexManagedOpenAIWebRefreshTests-widget-background-dashboard") + settings.statusChecksEnabled = false + if let codexMeta = ProviderRegistry.shared.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + let managedHomeURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try? Self.writeCodexAuthFile( + homeURL: managedHomeURL, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHomeURL) } + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: managedHomeURL.path, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + settings.openAIWebAccessEnabled = false + defer { settings._test_activeManagedCodexAccount = nil } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + + let dashboardBlocker = BlockingManagedOpenAIDashboardLoader() + let saver = RecordingWidgetSnapshotSaver() + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + + await store.refresh(forceTokenUsage: false) + await store.widgetSnapshotPersistTask?.value + settings.openAIWebAccessEnabled = true + store.snapshots[.codex] = Self.codexSnapshot(email: managedAccount.email, usedPercent: 18) + store.creditsRefreshTask = Task {} + store.creditsRefreshTaskKey = store.codexCreditsRefreshKey( + expectedGuard: store.currentCodexAccountScopedRefreshGuard()) + + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await dashboardBlocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + store._test_widgetSnapshotSaveOverride = { snapshot in + await saver.save(snapshot) + } + defer { store._test_widgetSnapshotSaveOverride = nil } + + let refreshTask = Task { + await store.refresh(forceTokenUsage: false) + } + + await refreshTask.value + let didPersistInitialRefreshSnapshot = await saver.waitUntilSavedWithin(count: 1) + #expect(didPersistInitialRefreshSnapshot) + + let firstSnapshots = await saver.savedSnapshots() + #expect(firstSnapshots.first?.entries.first { $0.provider == .codex }?.codeReviewRemainingPercent == nil) + + let backgroundTask = try #require(store.openAIDashboardBackgroundRefreshTask) + let didStartDashboardRefresh = await dashboardBlocker.waitUntilStartedWithin(count: 1) + #expect(didStartDashboardRefresh) + if didStartDashboardRefresh { + await dashboardBlocker.resumeNext(with: .success(OpenAIDashboardSnapshot( + signedInEmail: managedAccount.email, + codeReviewRemainingPercent: 95, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + creditsRemaining: 25, + accountPlan: "Pro", + updatedAt: Date()))) + await backgroundTask.value + } + let didPersistDashboardSnapshot = await saver.waitUntilSavedWithin(count: 2) + + #expect(didPersistDashboardSnapshot) + let secondSnapshots = await saver.savedSnapshots() + #expect(secondSnapshots.count >= 2) + } + @Test func `manual cookie import bypasses same account refresh coalescing`() async throws { let settings = try self.makeSettingsStore( suite: "CodexManagedOpenAIWebRefreshTests-manual-import-bypass-coalesce") let managedHome = FileManager.default.temporaryDirectory .appendingPathComponent("codex-managed-openai-web-refresh-\(UUID().uuidString)", isDirectory: true) + try Self.writeCodexAuthFile( + homeURL: managedHome, + email: "managed@example.com", + plan: "Pro") + defer { try? FileManager.default.removeItem(at: managedHome) } let managedAccount = ManagedCodexAccount( id: UUID(), email: "managed@example.com", @@ -29,7 +352,7 @@ struct CodexManagedOpenAIWebRefreshTests { settings: settings, startupBehavior: .testing) let blocker = BlockingManagedOpenAIDashboardLoader() - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -103,7 +426,7 @@ struct CodexManagedOpenAIWebRefreshTests { startupBehavior: .testing) store.openAIDashboardCookieImportStatus = "OpenAI cookies are for other@example.com, not managed@example.com." - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in throw ManagedDashboardTestError.networkTimeout } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -135,8 +458,10 @@ struct CodexManagedOpenAIWebRefreshTests { startupBehavior: .testing) let blocker = BlockingManagedOpenAIDashboardLoader() let importTracker = OpenAIDashboardImportCallTracker() - store._test_openAIDashboardLoaderOverride = { _, _, _ in - try await blocker.awaitResult() + var allowNavigationTimeoutRetries: [Bool] = [] + store._test_openAIDashboardLoaderOverride = { _, _, allowNavigationTimeoutRetry, _ in + allowNavigationTimeoutRetries.append(allowNavigationTimeoutRetry) + return try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in @@ -172,10 +497,65 @@ struct CodexManagedOpenAIWebRefreshTests { await refreshTask.value #expect(await blocker.startedCount() == 2) + #expect(allowNavigationTimeoutRetries == [true, true]) #expect(store.openAIDashboard?.creditsRemaining == 25) #expect(store.lastOpenAIDashboardError == nil) } + @Test + func `background navigation timeout skips immediate WebKit retry`() async throws { + let settings = try self.makeSettingsStore( + suite: "CodexManagedOpenAIWebRefreshTests-background-timeout-no-retry") + let managedAccount = ManagedCodexAccount( + id: UUID(), + email: "managed@example.com", + managedHomePath: "/tmp/managed-codex-home", + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1) + settings._test_activeManagedCodexAccount = managedAccount + settings.codexActiveSource = .managedAccount(id: managedAccount.id) + defer { settings._test_activeManagedCodexAccount = nil } + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let blocker = BlockingManagedOpenAIDashboardLoader() + let importTracker = OpenAIDashboardImportCallTracker() + var allowNavigationTimeoutRetries: [Bool] = [] + store._test_openAIDashboardLoaderOverride = { _, _, allowNavigationTimeoutRetry, _ in + allowNavigationTimeoutRetries.append(allowNavigationTimeoutRetry) + return try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + store._test_openAIDashboardCookieImportOverride = { targetEmail, _, _, _, _ in + _ = await importTracker.recordCall() + return OpenAIDashboardBrowserCookieImporter.ImportResult( + sourceLabel: "Chrome", + cookieCount: 2, + signedInEmail: targetEmail, + matchesCodexEmail: true) + } + defer { store._test_openAIDashboardCookieImportOverride = nil } + + let expectedGuard = store.currentCodexOpenAIWebRefreshGuard() + let refreshTask = Task { + await store.refreshOpenAIDashboardIfNeeded(force: false, expectedGuard: expectedGuard) + } + await blocker.waitUntilStarted(count: 1) + + await blocker.resumeNext(with: .failure(URLError(.timedOut))) + await refreshTask.value + + #expect(await blocker.startedCount() == 1) + #expect(allowNavigationTimeoutRetries == [false]) + #expect(await importTracker.callCount() == 0) + #expect(store.openAIDashboard == nil) + #expect(store.lastOpenAIDashboardError?.contains("timed out") == true) + } + @Test func `reset open A I web state blocks stale in flight dashboard completion`() async throws { let settings = try self.makeSettingsStore(suite: "CodexManagedOpenAIWebRefreshTests-reset-invalidates-task") @@ -196,7 +576,7 @@ struct CodexManagedOpenAIWebRefreshTests { settings: settings, startupBehavior: .testing) let blocker = BlockingManagedOpenAIDashboardLoader() - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } @@ -246,43 +626,15 @@ struct CodexManagedOpenAIWebRefreshTests { browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, startupBehavior: .testing) - let blocker = BlockingManagedOpenAIDashboardLoader() - let importTracker = OpenAIDashboardImportCallTracker() - store._test_openAIDashboardLoaderOverride = { _, _, _ in - try await blocker.awaitResult() + store.openAIDashboardCookieImportStatus = + "OpenAI cookies are for other@example.com, not managed@example.com." + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + throw ManagedDashboardTestError.networkTimeout } defer { store._test_openAIDashboardLoaderOverride = nil } - store._test_openAIDashboardCookieImportOverride = { _, _, _, _, _ in - let call = await importTracker.recordCall() - if call == 1 { - return OpenAIDashboardBrowserCookieImporter.ImportResult( - sourceLabel: "Chrome", - cookieCount: 2, - signedInEmail: managedAccount.email, - matchesCodexEmail: true) - } - throw OpenAIDashboardBrowserCookieImporter.ImportError.noMatchingAccount( - found: [.init(sourceLabel: "Chrome", email: "other@example.com")]) - } - defer { store._test_openAIDashboardCookieImportOverride = nil } let expectedGuard = store.currentCodexOpenAIWebRefreshGuard() - let firstTask = Task { - await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) - } - await blocker.waitUntilStarted(count: 1) - - let secondTask = Task { - await store.importOpenAIDashboardBrowserCookiesNow() - } - await blocker.waitUntilStarted(count: 2) - - await blocker.resumeNext(with: .failure(OpenAIDashboardFetcher.FetchError.loginRequired)) - await importTracker.waitUntilCalls(count: 2) - await blocker.resumeNext(with: .failure(ManagedDashboardTestError.networkTimeout)) - - await firstTask.value - await secondTask.value + await store.refreshOpenAIDashboardIfNeeded(force: true, expectedGuard: expectedGuard) #expect(store.lastOpenAIDashboardError == ManagedDashboardTestError.networkTimeout.localizedDescription) } @@ -312,6 +664,56 @@ struct CodexManagedOpenAIWebRefreshTests { settings.codexCookieSource = .auto return settings } + + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String, accountId: String? = nil) throws { + try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) + var tokens: [String: Any] = [ + "accessToken": "access-token", + "refreshToken": "refresh-token", + "idToken": Self.fakeJWT(email: email, plan: plan, accountId: accountId), + ] + if let accountId { + tokens["accountId"] = accountId + } + let data = try JSONSerialization.data(withJSONObject: ["tokens": tokens], options: [.sortedKeys]) + try data.write(to: homeURL.appendingPathComponent("auth.json")) + } + + private static func fakeJWT(email: String, plan: String, accountId: String? = nil) -> String { + let header = (try? JSONSerialization.data(withJSONObject: ["alg": "none"])) ?? Data() + var authClaims: [String: Any] = [ + "chatgpt_plan_type": plan, + ] + if let accountId { + authClaims["chatgpt_account_id"] = accountId + } + let payload = (try? JSONSerialization.data(withJSONObject: [ + "email": email, + "chatgpt_plan_type": plan, + "https://api.openai.com/auth": authClaims, + ])) ?? Data() + + func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "=", with: "") + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } + + return "\(base64URL(header)).\(base64URL(payload))." + } + + private static func codexSnapshot(email: String, usedPercent: Double) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: usedPercent, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: email, + accountOrganization: nil, + loginMethod: "Pro")) + } } private enum ManagedDashboardTestError: LocalizedError { @@ -325,16 +727,35 @@ private enum ManagedDashboardTestError: LocalizedError { } } -private actor BlockingManagedOpenAIDashboardLoader { +actor RefreshCompletionProbe { + private(set) var isCompleted = false + + func markCompleted() { + self.isCompleted = true + } + + func waitUntilCompleted(timeout: Duration = .seconds(5)) async -> Bool { + let startedAt = ContinuousClock.now + while !self.isCompleted { + if startedAt.duration(to: .now) >= timeout { + return false + } + try? await Task.sleep(for: .milliseconds(50)) + } + return true + } +} + +actor BlockingManagedOpenAIDashboardLoader { private var continuations: [CheckedContinuation, Never>] = [] private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] private var started: Int = 0 func awaitResult() async throws -> OpenAIDashboardSnapshot { - self.started += 1 - self.resumeReadyStartWaiters() let result = await withCheckedContinuation { continuation in self.continuations.append(continuation) + self.started += 1 + self.resumeReadyStartWaiters() } return try result.get() } @@ -346,6 +767,17 @@ private actor BlockingManagedOpenAIDashboardLoader { } } + func waitUntilStartedWithin(count: Int = 1, timeout: Duration = .seconds(5)) async -> Bool { + let startedAt = ContinuousClock.now + while self.started < count { + if startedAt.duration(to: .now) >= timeout { + return false + } + try? await Task.sleep(for: .milliseconds(50)) + } + return true + } + func startedCount() -> Int { self.started } @@ -369,6 +801,50 @@ private actor BlockingManagedOpenAIDashboardLoader { } } +actor BlockingCreditsLoader { + private var continuations: [CheckedContinuation, Never>] = [] + private var startWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var started = 0 + + func awaitResult() async throws -> CreditsSnapshot { + let result = await withCheckedContinuation { continuation in + self.continuations.append(continuation) + self.started += 1 + self.resumeReadyStartWaiters() + } + return try result.get() + } + + func waitUntilStarted(count: Int = 1) async { + if self.started >= count { return } + await withCheckedContinuation { continuation in + self.startWaiters.append((count: count, continuation: continuation)) + } + } + + func startedCount() -> Int { + self.started + } + + func resumeNext(with result: Result) { + guard !self.continuations.isEmpty else { return } + let continuation = self.continuations.removeFirst() + continuation.resume(returning: result) + } + + private func resumeReadyStartWaiters() { + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in self.startWaiters { + if self.started >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + self.startWaiters = remaining + } +} + private actor OpenAIDashboardImportCallTracker { private var calls: Int = 0 private var waiters: [(count: Int, continuation: CheckedContinuation)] = [] @@ -386,6 +862,10 @@ private actor OpenAIDashboardImportCallTracker { } } + func callCount() -> Int { + self.calls + } + private func resumeReadyWaiters() { var remaining: [(count: Int, continuation: CheckedContinuation)] = [] for waiter in self.waiters { diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift index fce3d009..921cce7a 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebTestSupport.swift @@ -33,7 +33,7 @@ extension CodexManagedOpenAIWebTests { settings: settings, startupBehavior: .testing) let blocker = CoalescingManagedOpenAIDashboardLoader() - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in try await blocker.awaitResult() } defer { store._test_openAIDashboardLoaderOverride = nil } diff --git a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift index da934f23..51cd309c 100644 --- a/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift +++ b/Tests/CodexBarTests/CodexManagedOpenAIWebTests.swift @@ -304,7 +304,7 @@ struct CodexManagedOpenAIWebTests { } var observedTargetEmail: String? - store._test_openAIDashboardLoaderOverride = { accountEmail, _, _ in + store._test_openAIDashboardLoaderOverride = { accountEmail, _, _, _ in observedTargetEmail = accountEmail return OpenAIDashboardSnapshot( signedInEmail: "new@example.com", @@ -365,7 +365,7 @@ struct CodexManagedOpenAIWebTests { store.lastSourceLabels[.codex] = "codex-cli" var observedTargetEmail: String? - store._test_openAIDashboardLoaderOverride = { accountEmail, _, _ in + store._test_openAIDashboardLoaderOverride = { accountEmail, _, _, _ in observedTargetEmail = accountEmail return OpenAIDashboardSnapshot( signedInEmail: "usage@example.com", @@ -818,7 +818,7 @@ struct CodexManagedOpenAIWebTests { startupBehavior: .testing) var loaderCalls = 0 - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in loaderCalls += 1 throw OpenAIDashboardFetcher.FetchError.loginRequired } @@ -860,7 +860,7 @@ struct CodexManagedOpenAIWebTests { settings: settings, startupBehavior: .testing) - store._test_openAIDashboardLoaderOverride = { _, _, _ in + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in throw OpenAIDashboardFetcher.FetchError.loginRequired } defer { store._test_openAIDashboardLoaderOverride = nil } diff --git a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift index dbcbd621..4fb88fc3 100644 --- a/Tests/CodexBarTests/CodexUserFacingErrorTests.swift +++ b/Tests/CodexBarTests/CodexUserFacingErrorTests.swift @@ -14,6 +14,30 @@ struct CodexUserFacingErrorTests { #expect(store.userFacingError(for: .codex) == CodexStatusProbeError.codexNotInstalled.localizedDescription) } + @Test + func `logged out codex CLI guidance is not collapsed to temporary outage`() { + let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-cli-login-required") + store.errors[.codex] = + "Codex connection failed: codex account authentication required to read rate limits" + + #expect( + store.userFacingError(for: .codex) == + "Codex CLI is not signed in. Run `codex login --device-auth`, then refresh.") + } + + @Test + func `cached logged out codex CLI failure preserves cached suffix`() { + let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-cached-cli-login-required") + store.lastCreditsError = + "Last Codex credits refresh failed: Codex connection failed: " + + "codex account authentication required to read rate limits. Cached values from 2m ago." + + #expect( + store.userFacingLastCreditsError == + "Codex CLI is not signed in. Run `codex login --device-auth`, then refresh. " + + "Cached values from 2m ago.") + } + @Test func `expired codex auth is sanitized`() { let store = self.makeUsageStore(suite: "CodexUserFacingErrorTests-expired-auth") diff --git a/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift b/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift index 631b8cbf..90d15059 100644 --- a/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift +++ b/Tests/CodexBarTests/CodexWebDashboardStrategyAuthorityTests.swift @@ -31,6 +31,39 @@ struct CodexWebDashboardStrategyAuthorityTests { #expect(result.credits?.remaining == 42) } + @Test + func `web dashboard attach preserves credits when usage limits are absent`() throws { + OpenAIDashboardCacheStore.clear() + defer { OpenAIDashboardCacheStore.clear() } + + let authHome = try self.makeAuthHome( + email: "owner@example.com", + accountId: "acct-owner") + defer { try? FileManager.default.removeItem(at: authHome) } + + let context = self.makeContext( + authHome: authHome, + knownOwners: [ + CodexDashboardKnownOwnerCandidate( + identity: .providerAccount(id: "acct-owner"), + normalizedEmail: "owner@example.com"), + ]) + let dashboard = self.makeDashboardWithoutUsageLimits(email: "owner@example.com") + + let result = try CodexWebDashboardStrategy.makeAuthorizedDashboardResultForTesting( + dashboard: dashboard, + context: context, + routingTargetEmail: "route@example.com") + + #expect(result.usage.primary == nil) + #expect(result.usage.secondary == nil) + #expect(result.usage.updatedAt == dashboard.updatedAt) + #expect(result.usage.identity?.accountEmail == "owner@example.com") + #expect(result.usage.identity?.loginMethod == "pro") + #expect(result.credits?.remaining == 42) + #expect(result.dashboard == dashboard) + } + @Test func `web dashboard display only throws typed policy error`() throws { OpenAIDashboardCacheStore.clear() @@ -332,6 +365,21 @@ struct CodexWebDashboardStrategyAuthorityTests { updatedAt: Date(timeIntervalSince1970: 2000)) } + private func makeDashboardWithoutUsageLimits(email: String) -> OpenAIDashboardSnapshot { + OpenAIDashboardSnapshot( + signedInEmail: email, + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: nil, + secondaryLimit: nil, + creditsRemaining: 42, + accountPlan: "pro", + updatedAt: Date(timeIntervalSince1970: 2000)) + } + private func makeAuthHome(email: String?, accountId: String? = nil) throws -> URL { let homeURL = FileManager.default.temporaryDirectory.appendingPathComponent( UUID().uuidString, diff --git a/Tests/CodexBarTests/ConfigValidationTests.swift b/Tests/CodexBarTests/ConfigValidationTests.swift index 638a49fa..da42392c 100644 --- a/Tests/CodexBarTests/ConfigValidationTests.swift +++ b/Tests/CodexBarTests/ConfigValidationTests.swift @@ -80,12 +80,26 @@ struct ConfigValidationTests { #expect(!issues.contains(where: { $0.provider == .azureopenai && $0.code == "enterprise_host_unused" })) } + @Test + func `allows OpenAI API project workspace ID`() { + var config = CodexBarConfig.makeDefault() + config.setProviderConfig(ProviderConfig(id: .openai, workspaceID: "proj_abc")) + let issues = CodexBarConfigValidator.validate(config) + + #expect(!issues.contains(where: { $0.provider == .openai && $0.code == "workspace_unused" })) + } + @Test func `warns on unsupported workspace ID`() { var config = CodexBarConfig.makeDefault() config.setProviderConfig(ProviderConfig(id: .gemini, workspaceID: "workspace-123")) let issues = CodexBarConfigValidator.validate(config) #expect(issues.contains(where: { $0.provider == .gemini && $0.code == "workspace_unused" })) + #expect(issues.contains(where: { issue in + issue.provider == .gemini && + issue.code == "workspace_unused" && + issue.message.contains("openai") + })) } @Test diff --git a/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift new file mode 100644 index 00000000..f1c98f03 --- /dev/null +++ b/Tests/CodexBarTests/CostHistoryChartMenuViewTests.swift @@ -0,0 +1,12 @@ +import Testing +@testable import CodexBar + +struct CostHistoryChartMenuViewTests { + @Test + @MainActor + func `window label keeps today for one day and dynamic labels otherwise`() { + #expect(CostHistoryChartMenuView.windowLabel(days: 1) == "Today") + #expect(CostHistoryChartMenuView.windowLabel(days: 7) == "Last 7 days") + #expect(CostHistoryChartMenuView.windowLabel(days: 30) == "Last 30 days") + } +} diff --git a/Tests/CodexBarTests/CostUsageCancellationTests.swift b/Tests/CodexBarTests/CostUsageCancellationTests.swift new file mode 100644 index 00000000..246603db --- /dev/null +++ b/Tests/CodexBarTests/CostUsageCancellationTests.swift @@ -0,0 +1,147 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageCancellationTests { + @Test + func `fetcher honors cancellation before token scan`() async throws { + let gate = AsyncCancellationGate() + let task = Task { + await gate.wait() + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + scannerOptions: CostUsageScanner.Options()) + } + await gate.waitUntilBlocked() + task.cancel() + await gate.open() + + await #expect(throws: CancellationError.self) { + _ = try await task.value + } + } + + @Test + func `codex scanner cancellation preserves existing cache`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 1, day: 2) + let iso = env.isoString(for: day) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session.jsonl", + contents: self.codexSessionContents(iso: iso, tokenLineCount: 1)) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(report.data.count == 1) + + let cacheURL = CostUsageCacheIO.cacheFileURL(provider: .codex, cacheRoot: env.cacheRoot) + let cacheBefore = try Data(contentsOf: cacheURL) + + try self.codexSessionContents(iso: iso, tokenLineCount: 20000) + .write(to: fileURL, atomically: true, encoding: .utf8) + + var checks = 0 + let checkCancellation: CostUsageScanner.CancellationCheck = { + checks += 1 + if checks >= 8 { + throw CancellationError() + } + } + + #expect(throws: CancellationError.self) { + _ = try CostUsageScanner.loadDailyReportCancellable( + provider: .codex, + since: day, + until: day, + now: day, + options: options, + checkCancellation: checkCancellation) + } + #expect(checks >= 8) + #expect(try Data(contentsOf: cacheURL) == cacheBefore) + } + + @Test + func `codex metadata pre scan honors cancellation`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 1, day: 2) + let iso = env.isoString(for: day) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "session-without-metadata.jsonl", + contents: Array(repeating: self.codexTokenLine(iso: iso), count: 20000).joined(separator: "\n") + "\n") + + var checks = 0 + let checkCancellation: CostUsageScanner.CancellationCheck = { + checks += 1 + if checks >= 3 { + throw CancellationError() + } + } + + #expect(throws: CancellationError.self) { + _ = try CostUsageScanner.parseCodexFileCancellable( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + checkCancellation: checkCancellation) + } + #expect(checks >= 3) + } + + private func codexSessionContents(iso: String, tokenLineCount: Int) -> String { + let session = #"{"type":"session_meta","payload":{"session_id":"session-1"}}"# + let context = #"{"type":"turn_context","timestamp":"\#(iso)","payload":{"model":"gpt-5"}}"# + return ([session, context] + Array(repeating: self.codexTokenLine(iso: iso), count: tokenLineCount)) + .joined(separator: "\n") + "\n" + } + + private func codexTokenLine(iso: String) -> String { + #"{"type":"event_msg","timestamp":"\#(iso)","payload":{"# + + #""type":"token_count","info":{"last_token_usage":{"# + + #""input_tokens":10,"cached_input_tokens":2,"output_tokens":4}}}}"# + } +} + +private actor AsyncCancellationGate { + private var blockedContinuation: CheckedContinuation? + private var openContinuation: CheckedContinuation? + private var isBlocked = false + private var isOpen = false + + func wait() async { + self.isBlocked = true + self.blockedContinuation?.resume() + self.blockedContinuation = nil + if self.isOpen { return } + await withCheckedContinuation { continuation in + self.openContinuation = continuation + } + } + + func waitUntilBlocked() async { + if self.isBlocked { return } + await withCheckedContinuation { continuation in + self.blockedContinuation = continuation + } + } + + func open() { + self.isOpen = true + self.openContinuation?.resume() + self.openContinuation = nil + } +} diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift new file mode 100644 index 00000000..df7761a3 --- /dev/null +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -0,0 +1,286 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageFetcherCacheSnapshotTests { + @Test + func `cached codex token snapshot loads from existing cache without rescanning`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + + #expect(cached?.sessionTokens == 42) + #expect(cached?.last30DaysTokens == 42) + #expect(cached?.daily.map(\.date) == ["2026-04-08"]) + } + + @Test + func `cached codex token snapshot refuses expanded or managed scopes`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + let expanded = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 7, + scannerOptions: options) + let managed = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + codexHomePath: env.codexHomeRoot.path, + historyDays: 1, + scannerOptions: options) + + #expect(expanded == nil) + #expect(managed == nil) + } + + @Test + func `cached codex token snapshot refuses mismatched roots fingerprint`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + scannerOptions: options) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache.roots = [env.root.appendingPathComponent("other/sessions", isDirectory: true).path: 0] + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + + #expect(cached == nil) + } + + @Test + func `cached codex token snapshot merges cached pi sessions`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + try Self.writePiCodexSessionFile(env: env, day: day, tokens: 165) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + refreshPricingInBackground: false, + scannerOptions: options, + piScannerOptions: piOptions) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + + #expect(cached?.sessionTokens == 207) + #expect(cached?.last30DaysTokens == 207) + } + + @Test + func `cached codex token snapshot loads cached pi sessions without native codex cache`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writePiCodexSessionFile(env: env, day: day, tokens: 165) + + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + _ = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: piOptions) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot)) + + #expect(cached?.sessionTokens == 165) + #expect(cached?.last30DaysTokens == 165) + } + + @Test + func `cached codex token snapshot still loads pi sessions when native cache roots mismatch`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + try Self.writePiCodexSessionFile(env: env, day: day, tokens: 165) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + refreshPricingInBackground: false, + scannerOptions: options, + piScannerOptions: piOptions) + + var cache = CostUsageCacheIO.load(provider: .codex, cacheRoot: env.cacheRoot) + cache.roots = [env.root.appendingPathComponent("other/sessions", isDirectory: true).path: 0] + CostUsageCacheIO.save(provider: .codex, cache: cache, cacheRoot: env.cacheRoot) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + now: day, + historyDays: 1, + scannerOptions: options) + + #expect(cached?.sessionTokens == 165) + #expect(cached?.last30DaysTokens == 165) + } + + private static func writeCodexSessionFile( + homeRoot: URL, + env: CostUsageTestEnvironment, + day: Date, + filename: String, + tokens: Int) throws + { + let comps = Calendar.current.dateComponents([.year, .month, .day], from: day) + let dir = homeRoot + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(String(format: "%04d", comps.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let model = "openai/gpt-5.4" + let url = dir.appendingPathComponent(filename, isDirectory: false) + try env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ], + ]).write(to: url, atomically: true, encoding: .utf8) + } + + private static func writePiCodexSessionFile( + env: CostUsageTestEnvironment, + day: Date, + tokens: Int) throws + { + _ = try env.writePiSessionFile( + relativePath: "nested/run-0/2026-04-08T10-00-00-000Z_test.jsonl", + contents: env.jsonl([ + [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": [ + "input": tokens, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": tokens, + ], + ], + ], + ])) + } +} diff --git a/Tests/CodexBarTests/CostUsagePricingTests.swift b/Tests/CodexBarTests/CostUsagePricingTests.swift index 4d4ea170..a9a90d2a 100644 --- a/Tests/CodexBarTests/CostUsagePricingTests.swift +++ b/Tests/CodexBarTests/CostUsagePricingTests.swift @@ -360,6 +360,22 @@ struct CostUsagePricingTests { #expect(cost == expected) } + @Test + func `claude cost supports opus48`() throws { + // Point at a fresh, empty cache root so the models.dev lookup misses and this + // exercises the built-in fallback table specifically — not a local cache hit. + let emptyCacheRoot = try Self.cacheRoot() + let cost = CostUsagePricing.claudeCostUSD( + model: "claude-opus-4-8", + inputTokens: 10, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + outputTokens: 5, + modelsDevCacheRoot: emptyCacheRoot) + let expected = (10.0 * 5e-6) + (5.0 * 2.5e-5) + #expect(cost == expected) + } + @Test func `claude cost returns nil for unknown models`() { let cost = CostUsagePricing.claudeCostUSD( diff --git a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift index 0eafae91..bb77320a 100644 --- a/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerBreakdownTests.swift @@ -194,6 +194,76 @@ struct CostUsageScannerBreakdownTests { #expect((second.data[0].costUSD ?? 0) > (first.data[0].costUSD ?? 0)) } + @Test + func `codex incremental append falls back to rescan when fork metadata appears late`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let iso0 = env.isoString(for: day) + let iso1 = env.isoString(for: day.addingTimeInterval(1)) + let iso2 = env.isoString(for: day.addingTimeInterval(2)) + let iso3 = env.isoString(for: day.addingTimeInterval(3)) + let model = "gpt-5.4" + let sessionMeta: [String: Any] = [ + "type": "session_meta", + "timestamp": iso0, + "payload": ["id": "late-fork-child"], + ] + let turnContext = self.codexTurnContext(timestamp: iso0, model: model) + let firstTokenCount = self.codexTokenCount( + timestamp: iso1, + model: model, + total: (input: 10, cached: 0, output: 0), + last: (input: 10, cached: 0, output: 0)) + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "late-fork-child.jsonl", + contents: env.jsonl([sessionMeta, turnContext, firstTokenCount])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + options.refreshMinIntervalSeconds = 0 + + let first = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(first.data.first?.totalTokens == 10) + + let lateForkMeta: [String: Any] = [ + "type": "session_meta", + "timestamp": iso2, + "payload": [ + "id": "late-fork-child", + "forked_from_id": "missing-parent", + "timestamp": iso2, + ], + ] + let replayedForkUsage = self.codexTokenCount( + timestamp: iso3, + model: model, + total: (input: 1000, cached: 900, output: 100), + last: (input: 1000, cached: 900, output: 100)) + let handle = try FileHandle(forWritingTo: fileURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data(("\n" + env.jsonl([lateForkMeta, replayedForkUsage])).utf8)) + try handle.close() + + let second = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + #expect(second.data.first?.totalTokens == 10) + } + @Test func `codex daily report reprices cached sessions when models dev pricing changes`() throws { let env = try CostUsageTestEnvironment() @@ -1552,6 +1622,285 @@ struct CostUsageScannerBreakdownTests { #expect(abs((report.data[0].costUSD ?? 0) - (expectedCost ?? 0)) < 0.000001) } + @Test + func `codex forked child skips cumulative totals when parent session is missing`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let parentDay = try env.makeLocalNoon(year: 2026, month: 2, day: 27) + let childDay = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let missingParentSessionId = "sess-parent-deleted" + let childSessionId = "sess-child-deleted-parent" + let forkTs = env.isoString(for: parentDay.addingTimeInterval(2.5)) + + _ = try env.writeCodexSessionFile( + day: childDay, + filename: "rollout-2026-03-11T11-30-27-\(childSessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": childSessionId, + "forked_from_id": missingParentSessionId, + "timestamp": forkTs, + ], + ], + self.codexTurnContext(timestamp: env.isoString(for: childDay), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(1)), + model: model, + total: (input: 1_000_000, cached: 100_000, output: 10000)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(2)), + model: model, + total: (input: 1_000_120, cached: 100_010, output: 10020)), + self.codexTokenCount( + timestamp: env.isoString(for: childDay.addingTimeInterval(3)), + model: model, + total: (input: 1_000_140, cached: 100_012, output: 10023), + last: (input: 20, cached: 2, output: 3)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + options.forceRescan = true + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: childDay, + until: childDay, + now: childDay, + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 20) + #expect(report.data[0].outputTokens == 3) + #expect(report.data[0].totalTokens == 23) + } + + @Test + func `codex fork with total usage ignores replayed last snapshots`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.4" + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(iso0)-child-session.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": iso0, + "payload": [ + "id": "child-session", + "forked_from_id": "parent-session", + "timestamp": iso0, + ], + ], + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1000, cached: 900, output: 100), + last: (input: 1000, cached: 900, output: 100)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 1100, cached: 920, output: 110), + last: (input: 40, cached: 20, output: 5)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 1100, cached: 920, output: 110), + last: (input: 40, cached: 20, output: 5)), + ])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: range, + inheritedTotalsResolver: { parentSessionId, forkedAt in + #expect(parentSessionId == "parent-session") + #expect(forkedAt == iso0) + return .resolved(.init(input: 1000, cached: 900, output: 100)) + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + let packed = parsed.days[dayKey]?[normalized] ?? [] + #expect(packed.count >= 3) + #expect(packed[0] == 100) + #expect(packed[1] == 20) + #expect(packed[2] == 10) + #expect(parsed.rows.count == 1) + #expect(parsed.rows.first?.input == 100) + #expect(parsed.rows.first?.cached == 20) + #expect(parsed.rows.first?.output == 10) + } + + @Test + func `codex fork skips last usage when parent baseline is unresolved`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.4" + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(iso0)-missing-parent.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": iso0, + "payload": [ + "id": "child-session", + "forked_from_id": "missing-parent", + "timestamp": iso0, + ], + ], + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1000, cached: 900, output: 100), + last: (input: 1000, cached: 900, output: 100)), + ])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: range, + inheritedTotalsResolver: { parentSessionId, _ in + #expect(parentSessionId == "missing-parent") + return .unresolved + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + #expect(parsed.days[dayKey]?[normalized] == nil) + #expect(parsed.rows.isEmpty) + } + + @Test + func `codex unresolved fork ignores duplicated total and last replay after prefix`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let iso0 = env.isoString(for: day) + let model = "openai/gpt-5.4" + + let fileURL = try env.writeCodexSessionFile( + day: day, + filename: "rollout-\(iso0)-missing-parent-replay.jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "timestamp": iso0, + "payload": [ + "id": "child-session", + "forked_from_id": "missing-parent", + "timestamp": iso0, + ], + ], + self.codexTurnContext(timestamp: iso0, model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(1)), + model: model, + total: (input: 1000, cached: 900, output: 100)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 1020, cached: 905, output: 105), + last: (input: 20, cached: 5, output: 5)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 1020, cached: 905, output: 105), + last: (input: 20, cached: 5, output: 5)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(4)), + model: model, + total: (input: 1030, cached: 907, output: 108), + last: (input: 10, cached: 2, output: 3)), + ])) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day) + let parsed = CostUsageScanner.parseCodexFile( + fileURL: fileURL, + range: range, + inheritedTotalsResolver: { parentSessionId, _ in + #expect(parentSessionId == "missing-parent") + return .unresolved + }) + + let dayKey = CostUsageScanner.CostUsageDayRange.dayKey(from: day) + let normalized = CostUsagePricing.normalizeCodexModel(model) + let packed = try #require(parsed.days[dayKey]?[normalized]) + #expect(packed[0] == 30) + #expect(packed[1] == 7) + #expect(packed[2] == 8) + #expect(parsed.rows.count == 2) + } + + @Test + func `codex empty fork parent id still counts cumulative totals`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 3, day: 11) + let model = "openai/gpt-5.2-codex" + let sessionId = "sess-empty-fork-parent" + + _ = try env.writeCodexSessionFile( + day: day, + filename: "rollout-2026-03-11T11-30-27-\(sessionId).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", + "payload": [ + "id": sessionId, + "forked_from_id": "", + "timestamp": env.isoString(for: day), + ], + ], + self.codexTurnContext(timestamp: env.isoString(for: day.addingTimeInterval(1)), model: model), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(2)), + model: model, + total: (input: 100, cached: 10, output: 5)), + self.codexTokenCount( + timestamp: env.isoString(for: day.addingTimeInterval(3)), + model: model, + total: (input: 125, cached: 12, output: 8)), + ])) + + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: nil, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + options.forceRescan = true + + let report = CostUsageScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + + #expect(report.data.count == 1) + #expect(report.data[0].inputTokens == 125) + #expect(report.data[0].outputTokens == 8) + #expect(report.data[0].totalTokens == 133) + } + @Test func `codex forked child inherits counted parent totals when totals diverge`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift b/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift index 7600dfc0..5cacd77a 100644 --- a/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift +++ b/Tests/CodexBarTests/CostUsageScannerClaudeRegressionTests.swift @@ -158,6 +158,54 @@ struct CostUsageScannerClaudeRegressionTests { #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) } + /// Regression for https://github.com/steipete/CodexBar/issues/1210: an Opus 4.8 row + /// priced to an empty cost because the built-in Claude pricing table had no + /// claude-opus-4-8 entry (used when the models.dev cache is missing/stale). + @Test + func `claude opus 4 8 issue row gets priced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 5, day: 29) + let fileURL = try env.writeClaudeProjectFile( + relativePath: "project-a/opus-48.jsonl", + contents: env.jsonl([ + [ + "message": [ + "model": "claude-opus-4-8", + "id": "msg_01NrvWoSMk2Eig6vkCgyRZqc", + "type": "message", + "role": "assistant", + "usage": [ + "input_tokens": 6, + "cache_creation_input_tokens": 1389, + "cache_read_input_tokens": 50352, + "output_tokens": 3922, + ], + ], + "requestId": "req_011CaLLcFQD712ZnCTxHFk71", + "type": "assistant", + "timestamp": "2026-05-29T07:51:34.428Z", + "sessionId": "39d4b923-8273-4c35-ad9c-e098395286f1", + ], + ])) + + let parsed = CostUsageScanner.parseClaudeFile( + fileURL: fileURL, + range: CostUsageScanner.CostUsageDayRange(since: day, until: day), + providerFilter: .all) + + #expect(parsed.rows.count == 1) + #expect(parsed.rows[0].model == "claude-opus-4-8") + #expect(parsed.rows[0].input == 6) + #expect(parsed.rows[0].cacheCreate == 1389) + #expect(parsed.rows[0].cacheRead == 50352) + #expect(parsed.rows[0].output == 3922) + + let expected = 0.13193725 + #expect(abs((Double(parsed.rows[0].costNanos) / 1_000_000_000) - expected) < 0.000000001) + } + @Test func `claude streaming keeps the last cumulative chunk`() throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift b/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift new file mode 100644 index 00000000..c7cc32b9 --- /dev/null +++ b/Tests/CodexBarTests/DeepSeekUsageCostParserTests.swift @@ -0,0 +1,855 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct DeepSeekUsageCostParserTests { + // Fixtures use date 2026-05-26 + private let fixtureNow = Date(timeIntervalSince1970: 1_779_796_800) // 2026-05-26 12:00:00 UTC + private let fixtureCalendar: Calendar = { + var cal = Calendar.current + cal.timeZone = TimeZone(identifier: "UTC") ?? .current + return cal + }() + + // MARK: - Amount Parser Tests + + @Test + func `amount parser decodes total and days`() throws { + let json = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1305432"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"}, + {"type": "REQUEST", "amount": "1212"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1305432"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"}, + {"type": "REQUEST", "amount": "1212"} + ] + } + ] + } + ] + } + } + } + """ + let payload = try DeepSeekUsageCostParser.decodeAmountPayload(data: Data(json.utf8)) + #expect(payload.code == 0) + #expect(payload.data?.bizCode == 0) + #expect(payload.data?.bizData?.total?.count == 1) + #expect(payload.data?.bizData?.total?[0].model == "deepseek-v4-flash") + #expect(payload.data?.bizData?.days?.count == 1) + #expect(payload.data?.bizData?.days?[0].date == "2026-05-26") + } + + @Test + func `amount parser handles missing biz_data gracefully`() throws { + let json = """ + { + "code": 0, + "msg": "", + "data": null + } + """ + let payload = try DeepSeekUsageCostParser.decodeAmountPayload(data: Data(json.utf8)) + #expect(payload.code == 0) + #expect(payload.data?.bizData?.total == nil) + #expect(payload.data?.bizData?.days == nil) + } + + // MARK: - Cost Parser Tests + + @Test + func `cost parser decodes total, days, and currency`() throws { + let json = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1.3054320000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"}, + {"type": "REQUEST", "amount": "0"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1.3054320000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"}, + {"type": "REQUEST", "amount": "0"} + ] + } + ] + } + ], + "currency": "CNY" + } + ] + } + } + """ + let payload = try DeepSeekUsageCostParser.decodeCostPayload(data: Data(json.utf8)) + #expect(payload.code == 0) + #expect(payload.data?.bizCode == 0) + #expect(payload.data?.bizData?[0].currency == "CNY") + #expect(payload.data?.bizData?[0].total?.count == 1) + #expect(payload.data?.bizData?[0].days?.count == 1) + #expect(payload.data?.bizData?[0].days?[0].date == "2026-05-26") + } + + @Test + func `cost parser handles empty biz_data`() throws { + let json = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [] + } + } + """ + let payload = try DeepSeekUsageCostParser.decodeCostPayload(data: Data(json.utf8)) + #expect(payload.code == 0) + #expect(payload.data?.bizData?.isEmpty == true) + } + + // MARK: - String Parsing Tests + + @Test + func `string token parsing works`() throws { + let json = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"} + ] + } + ], + "days": [] + } + } + } + """ + let payload = try DeepSeekUsageCostParser.decodeAmountPayload(data: Data(json.utf8)) + #expect(payload.data?.bizData?.total?[0].usage?[0].type == "PROMPT_CACHE_HIT_TOKEN") + #expect(payload.data?.bizData?.total?[0].usage?[0].amount == "100686720") + } + + @Test + func `decimal cost parsing works`() throws { + let json = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"} + ] + } + ], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + let payload = try DeepSeekUsageCostParser.decodeCostPayload(data: Data(json.utf8)) + #expect(payload.data?.bizData?[0].total?[0].usage?[0].amount == "2.0137344000000000") + } + + // MARK: - Aggregation Tests + + @Test + func `aggregation computes today token totals`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1305432"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"}, + {"type": "REQUEST", "amount": "1212"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1305432"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"}, + {"type": "REQUEST", "amount": "1212"} + ] + } + ] + } + ] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1.3054320000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"}, + {"type": "REQUEST", "amount": "0"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1.3054320000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"}, + {"type": "REQUEST", "amount": "0"} + ] + } + ] + } + ], + "currency": "CNY" + } + ] + } + } + """ + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + + // Today is 2026-05-26 per the test data + #expect(summary.todayTokens == 102_648_490) // 100_686_720 + 1_305_432 + 656_338 + #expect(summary.requestCount == 1212) + #expect(summary.currency == "CNY") + } + + @Test + func `aggregation uses injected now and calendar for today bucket`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100"}, + {"type": "REQUEST", "amount": "1"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100"}, + {"type": "REQUEST", "amount": "1"} + ] + } + ] + } + ] + } + } + } + """ + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + var nextMonthUTC = Calendar(identifier: .gregorian) + nextMonthUTC.timeZone = TimeZone(identifier: "UTC") ?? .current + let injectedNow = try #require(nextMonthUTC.date(from: DateComponents(year: 2026, month: 6, day: 1, hour: 12))) + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: injectedNow, + calendar: nextMonthUTC) + + #expect(summary.todayTokens == 0) + #expect(summary.currentMonthTokens == 0) + } + + @Test + func `aggregation computes today cost totals`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"} + ] + } + ] + } + ] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"} + ] + } + ] + } + ], + "currency": "CNY" + } + ] + } + } + """ + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + + #expect(abs((summary.todayCost ?? 0) - 3.3264104) < 0.0001) + #expect(summary.currentMonthCost != nil) + } + + @Test + func `aggregation computes model and category breakdown`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100686720"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1305432"}, + {"type": "RESPONSE_TOKEN", "amount": "656338"} + ] + } + ], + "days": [] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0137344000000000"}, + {"type": "PROMPT_CACHE_MISS_TOKEN", "amount": "1.3054320000000000"}, + {"type": "RESPONSE_TOKEN", "amount": "1.3126760000000000"} + ] + } + ], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + + #expect(summary.topModel == "deepseek-v4-flash") + #expect(summary.categoryBreakdown.count == 3) + + let cacheHit = summary.categoryBreakdown.first { $0.category == DeepSeekUsageCategory.promptCacheHitToken } + #expect(cacheHit?.tokens == 100_686_720) + #expect(abs((cacheHit?.cost ?? 0) - 2.0137344) < 0.0001) + + let cacheMiss = summary.categoryBreakdown.first { $0.category == DeepSeekUsageCategory.promptCacheMissToken } + #expect(cacheMiss?.tokens == 1_305_432) + #expect(abs((cacheMiss?.cost ?? 0) - 1.305432) < 0.0001) + + let response = summary.categoryBreakdown.first { $0.category == DeepSeekUsageCategory.responseToken } + #expect(response?.tokens == 656_338) + #expect(abs((response?.cost ?? 0) - 1.312676) < 0.0001) + } + + // MARK: - Unknown Types Handling + + @Test + func `unknown usage types are ignored safely`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100"}, + {"type": "UNKNOWN_TYPE", "amount": "999"}, + {"type": "RESPONSE_TOKEN", "amount": "200"} + ] + } + ], + "days": [] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "1.0"}, + {"type": "UNKNOWN_TYPE", "amount": "99.0"}, + {"type": "RESPONSE_TOKEN", "amount": "2.0"} + ] + } + ], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + + // Unknown type should be ignored - only known categories with non-zero tokens appear in breakdown + // todayTokens comes from daily data which is empty in this test, so it's 0 + #expect(summary.todayTokens == 0) + #expect(summary.categoryBreakdown.count == 3) // Always 3 categories, even if some have 0 tokens + } + + // MARK: - Error Handling + + @Test + func `missing fields fails closed`() { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": null + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: fixtureNow, + calendar: fixtureCalendar) + } throws: { error in + guard case DeepSeekUsageError.parseFailed = error else { return false } + return true + } + } + + @Test + func `non-zero biz_code fails closed`() { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 1001, + "biz_msg": "some error", + "biz_data": { + "total": [], + "days": [] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8)) + } throws: { error in + guard case DeepSeekUsageError.apiError = error else { return false } + return true + } + } + + @Test + func `invalid JSON fails closed`() { + #expect { + _ = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data("not json".utf8), + costData: Data("{}".utf8)) + } throws: { error in + guard case DeepSeekUsageError.parseFailed = error else { return false } + return true + } + } + + // MARK: - Edge Cases + + @Test + func `empty days array works`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [], + "days": [] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + + #expect(summary.todayTokens == 0) + #expect(summary.currentMonthTokens == 0) + #expect(summary.todayCost == nil) + #expect(summary.currentMonthCost == nil) + #expect(summary.daily.isEmpty) + } + + @Test + func `multiple models works`() throws { + let amountJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100"}, + {"type": "RESPONSE_TOKEN", "amount": "50"} + ] + }, + { + "model": "deepseek-chat", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "200"}, + {"type": "RESPONSE_TOKEN", "amount": "100"} + ] + } + ], + "days": [ + { + "date": "2026-05-26", + "data": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "100"}, + {"type": "RESPONSE_TOKEN", "amount": "50"} + ] + }, + { + "model": "deepseek-chat", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "200"}, + {"type": "RESPONSE_TOKEN", "amount": "100"} + ] + } + ] + } + ] + } + } + } + """ + + let costJSON = """ + { + "code": 0, + "msg": "", + "data": { + "biz_code": 0, + "biz_msg": "", + "biz_data": [ + { + "total": [ + { + "model": "deepseek-v4-flash", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "1.0"}, + {"type": "RESPONSE_TOKEN", "amount": "0.5"} + ] + }, + { + "model": "deepseek-chat", + "usage": [ + {"type": "PROMPT_CACHE_HIT_TOKEN", "amount": "2.0"}, + {"type": "RESPONSE_TOKEN", "amount": "1.0"} + ] + } + ], + "days": [], + "currency": "CNY" + } + ] + } + } + """ + + let summary = try DeepSeekUsageFetcher._parseUsageSummaryForTesting( + amountData: Data(amountJSON.utf8), + costData: Data(costJSON.utf8), + now: self.fixtureNow, + calendar: self.fixtureCalendar) + + #expect(summary.topModel == "deepseek-chat") // 300 tokens vs 150 tokens + #expect(summary.todayTokens == 450) // 150 + 300 + } +} diff --git a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift index 8bac9bd2..e4d60078 100644 --- a/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift +++ b/Tests/CodexBarTests/DeepSeekUsageFetcherTests.swift @@ -3,6 +3,88 @@ import Testing @testable import CodexBarCore struct DeepSeekUsageFetcherTests { + private struct TimeoutError: Error {} + + private actor SummaryCancellationProbe { + private var started = false + private var cancelled = false + private var startedWaiters: [CheckedContinuation] = [] + + func markStarted() { + self.started = true + for waiter in self.startedWaiters { + waiter.resume() + } + self.startedWaiters.removeAll() + } + + func waitUntilStarted() async { + if self.started { return } + await withCheckedContinuation { continuation in + self.startedWaiters.append(continuation) + } + } + + func markCancelled() { + self.cancelled = true + } + + func wasCancelled() -> Bool { + self.cancelled + } + } + + private static func withTimeout( + _ timeout: Duration, + operation: @escaping @Sendable () async throws -> T) async throws -> T + { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + group.addTask { + try await Task.sleep(for: timeout) + throw TimeoutError() + } + + let result = try await group.next() + group.cancelAll() + guard let result else { throw TimeoutError() } + return result + } + } + + private static let sampleBalanceJSON = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "50.00", + "granted_balance": "10.00", + "topped_up_balance": "40.00" + } + ] + } + """ + + private static func sampleSummary(updatedAt: Date = Date()) -> DeepSeekUsageSummary { + DeepSeekUsageSummary( + todayTokens: 123, + currentMonthTokens: 456, + todayCost: 1.23, + currentMonthCost: 4.56, + requestCount: 7, + currentMonthRequestCount: 8, + topModel: "deepseek-v4-flash", + categoryBreakdown: [ + DeepSeekCategoryBreakdown(category: .promptCacheHitToken, tokens: 123, cost: 1.23), + ], + daily: [], + currency: "USD", + updatedAt: updatedAt) + } + @Test func `parses USD balance response`() throws { let json = """ @@ -237,4 +319,209 @@ struct DeepSeekUsageFetcherTests { let detail = usage.primary?.resetDescription ?? "" #expect(detail.contains("¥")) } + + @Test + func `balance snapshot has nil usage summary`() throws { + let json = """ + { + "is_available": true, + "balance_infos": [ + { + "currency": "USD", + "total_balance": "50.00", + "granted_balance": "10.00", + "topped_up_balance": "40.00" + } + ] + } + """ + let snapshot = try DeepSeekUsageFetcher._parseSnapshotForTesting(Data(json.utf8)) + let usage = snapshot.toUsageSnapshot() + #expect(usage.deepseekUsage == nil) + } + + @Test + func `balance returns promptly when optional usage summary is slow`() async throws { + let probe = SummaryCancellationProbe() + let snapshot = try await Self.withTimeout(.seconds(10)) { + try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .milliseconds(50), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + await probe.markStarted() + do { + try await Task.sleep(for: .seconds(60)) + return Self.sampleSummary() + } catch is CancellationError { + await probe.markCancelled() + throw CancellationError() + } + }) + } + + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.usageSummary == nil) + #expect(await probe.wasCancelled()) + } + + @Test + func `balance returns when optional usage summary fails closed`() async throws { + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(2), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + throw DeepSeekUsageError.networkError("simulated failure") + }) + + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.usageSummary == nil) + } + + @Test + func `cancels optional usage summary when balance fetch fails`() async throws { + let probe = SummaryCancellationProbe() + + do { + _ = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(2), + fetchBalanceData: { _ in + await probe.waitUntilStarted() + throw DeepSeekUsageError.networkError("simulated balance failure") + }, + fetchSummary: { _ in + await probe.markStarted() + do { + try await Task.sleep(for: .seconds(1)) + return Self.sampleSummary() + } catch is CancellationError { + await probe.markCancelled() + throw DeepSeekUsageError.networkError("cancelled") + } + }) + Issue.record("Expected balance failure") + } catch DeepSeekUsageError.networkError { + try await Task.sleep(for: .milliseconds(100)) + #expect(await probe.wasCancelled()) + } + } + + @Test + func `cancels optional usage summary when balance parsing fails`() async throws { + let probe = SummaryCancellationProbe() + + do { + _ = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(2), + fetchBalanceData: { _ in + await probe.waitUntilStarted() + return Data("{\"is_available\":true,\"balance_infos\":[".utf8) + }, + fetchSummary: { _ in + await probe.markStarted() + do { + try await Task.sleep(for: .seconds(1)) + return Self.sampleSummary() + } catch is CancellationError { + await probe.markCancelled() + throw DeepSeekUsageError.networkError("cancelled") + } + }) + Issue.record("Expected balance parse failure") + } catch DeepSeekUsageError.parseFailed { + try await Task.sleep(for: .milliseconds(100)) + #expect(await probe.wasCancelled()) + } + } + + @Test + func `parent cancellation propagates while waiting for optional usage summary`() async throws { + let probe = SummaryCancellationProbe() + let task = Task { + try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(30), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + await probe.markStarted() + do { + try await Task.sleep(for: .seconds(60)) + return Self.sampleSummary() + } catch is CancellationError { + await probe.markCancelled() + throw CancellationError() + } + }) + } + + await probe.waitUntilStarted() + task.cancel() + + do { + _ = try await Self.withTimeout(.seconds(10)) { + try await task.value + } + Issue.record("Expected cancellation") + } catch is CancellationError { + #expect(await probe.wasCancelled()) + } + } + + @Test + func `usage period defaults to Gregorian API calendar`() throws { + let date = try #require(Self.utcDate(year: 2026, month: 5, day: 26)) + let period = try DeepSeekUsageFetcher._apiUsagePeriodForTesting(now: date) + + #expect(period.month == 5) + #expect(period.year == 2026) + } + + @Test + func `usage period supports injected test calendar`() throws { + var calendar = Calendar(identifier: .buddhist) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + let date = try #require(Self.utcDate(year: 2026, month: 5, day: 26)) + let period = try DeepSeekUsageFetcher._apiUsagePeriodForTesting(now: date, calendar: calendar) + + #expect(period.month == 5) + #expect(period.year == 2569) + } + + @Test + func `production path can populate usage summary when optional fetch succeeds`() async throws { + let expected = Self.sampleSummary() + let snapshot = try await DeepSeekUsageFetcher._fetchUsageForTesting( + apiKey: "test-key", + includeOptionalUsage: true, + optionalSummaryJoinGrace: .seconds(2), + fetchBalanceData: { _ in + Data(Self.sampleBalanceJSON.utf8) + }, + fetchSummary: { _ in + expected + }) + + #expect(snapshot.totalBalance == 50.0) + #expect(snapshot.usageSummary == expected) + } + + private static func utcDate(year: Int, month: Int, day: Int) -> Date? { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + return calendar.date(from: DateComponents(year: year, month: month, day: day)) + } } diff --git a/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift b/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift index f25c0930..5c01dd2d 100644 --- a/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift +++ b/Tests/CodexBarTests/FactoryStatusProbeFetchTests.swift @@ -83,12 +83,13 @@ struct FactoryStatusProbeFetchTests { } let probe = FactoryStatusProbe( - timeout: 0.1, + timeout: 1.0, browserDetection: BrowserDetection( homeDirectory: "/tmp/codexbar-empty-browser-home", cacheTTL: 0, fileExists: { _ in false }, - directoryContents: { _ in nil })) + directoryContents: { _ in nil }), + transport: FactoryStubTransport()) let snapshot = try await probe.fetch() @@ -200,12 +201,13 @@ struct FactoryStatusProbeFetchTests { } let probe = FactoryStatusProbe( - timeout: 0.1, + timeout: 1.0, browserDetection: BrowserDetection( homeDirectory: "/tmp/codexbar-empty-browser-home", cacheTTL: 0, fileExists: { _ in false }, - directoryContents: { _ in nil })) + directoryContents: { _ in nil }), + transport: FactoryStubTransport()) let snapshot = try await probe.fetch() @@ -788,3 +790,14 @@ final class FactoryStubURLProtocol: URLProtocol { override func stopLoading() {} } + +private struct FactoryStubTransport: ProviderHTTPTransport { + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + guard let handler = FactoryStubURLProtocol.handler else { + throw URLError(.badServerResponse) + } + FactoryStubURLProtocol.requests.append(request) + let (response, data) = try handler(request) + return (data, response) + } +} diff --git a/Tests/CodexBarTests/GrokBillingResponseTests.swift b/Tests/CodexBarTests/GrokBillingResponseTests.swift index 5d038a50..f0c5e27a 100644 --- a/Tests/CodexBarTests/GrokBillingResponseTests.swift +++ b/Tests/CodexBarTests/GrokBillingResponseTests.swift @@ -29,6 +29,7 @@ struct GrokBillingResponseTests { #expect(response.usage?.totalUsed?.val == 49950) #expect(response.monthlyUsedPercent == 50.0) #expect(response.billingPeriodEndDate != nil) + #expect(response.billingPeriodMinutes == 31 * 24 * 60) } @Test diff --git a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift index 3122e859..38b19a81 100644 --- a/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift +++ b/Tests/CodexBarTests/GrokWebBillingFetcherTests.swift @@ -27,6 +27,33 @@ struct GrokWebBillingFetcherTests { #expect(GrokProviderDescriptor.descriptor.fetchPlan.sourceModes == [.auto, .cli, .web]) } + @Test + func `descriptor uses Credits label for primary usage window`() { + let metadata = GrokProviderDescriptor.descriptor.metadata + #expect(metadata.sessionLabel == "Credits") + #expect(metadata.weeklyLabel == "On-demand") + #expect(!metadata.supportsOpus) + } + + @Test + func `primaryLabel derives Weekly or Monthly from resetsAt`() { + let now = Date() + let in6Days = now.addingTimeInterval(6 * 86400) + let in30Days = now.addingTimeInterval(30 * 86400) + let in90Days = now.addingTimeInterval(90 * 86400) + let lateWeeklyWindow = RateWindow( + usedPercent: 25, + windowMinutes: 7 * 24 * 60, + resetsAt: now.addingTimeInterval(86400), + resetDescription: nil) + + #expect(GrokProviderDescriptor.primaryLabel(resetsAt: in6Days, now: now) == "Weekly") + #expect(GrokProviderDescriptor.primaryLabel(resetsAt: in30Days, now: now) == "Monthly") + #expect(GrokProviderDescriptor.primaryLabel(resetsAt: in90Days, now: now) == nil) + #expect(GrokProviderDescriptor.primaryLabel(window: lateWeeklyWindow, now: now) == "Weekly") + #expect(GrokProviderDescriptor.primaryLabel(resetsAt: nil) == nil) + } + @Test func `cli runtime does not import browser cookies unless explicitly enabled`() { #expect(GrokWebFetchStrategy.canImportBrowserCookies(runtime: .app, env: [:])) @@ -506,6 +533,7 @@ struct GrokWebBillingFetcherTests { let usage = snapshot.toUsageSnapshot() #expect(usage.primary?.usedPercent == 67.25) + #expect(usage.primary?.windowMinutes == nil) #expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_800_000_003)) #expect(usage.accountEmail(for: .grok) == "grok@example.com") #expect(usage.loginMethod(for: .grok) == "SuperGrok") diff --git a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift new file mode 100644 index 00000000..2584e5df --- /dev/null +++ b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift @@ -0,0 +1,124 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct InlineCostHistoryDashboardLabelTests { + @Test + func `local cost history KPI titles preserve one day and dynamic windows`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let daily = [ + CostUsageDailyReport.Entry( + date: "2023-11-14", + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + costUSD: 0.12, + modelsUsed: ["claude-sonnet-4"], + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: ["claude-opus-4"], + modelBreakdowns: nil), + ] + + func makeModel(historyDays: Int) -> UsageMenuCardView.Model { + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 275, + sessionCostUSD: 0.25, + last30DaysTokens: 425, + last30DaysCostUSD: 0.37, + historyDays: historyDays, + daily: daily, + updatedAt: now) + return UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + } + + let oneDay = makeModel(historyDays: 1) + #expect(oneDay.inlineUsageDashboard?.kpis[1].title == "Today") + #expect(oneDay.inlineUsageDashboard?.kpis[2].title == "Today tokens") + + let sevenDays = makeModel(historyDays: 7) + #expect(sevenDays.inlineUsageDashboard?.kpis[1].title == "Last 7 days Cost") + #expect(sevenDays.inlineUsageDashboard?.kpis[2].title == "Last 7 days tokens") + + let thirtyDays = makeModel(historyDays: 30) + #expect(thirtyDays.inlineUsageDashboard?.kpis[1].title == "30d cost") + #expect(thirtyDays.inlineUsageDashboard?.kpis[2].title == "30d tokens") + } + + @Test + func `custom cost history KPI title keeps token label distinct`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 275, + sessionCostUSD: 0.25, + last30DaysTokens: 425, + last30DaysCostUSD: 0.37, + historyLabel: "This month", + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis[1].title == "This month") + #expect(model.inlineUsageDashboard?.kpis[2].title == "This month tokens") + } +} diff --git a/Tests/CodexBarTests/KeychainCacheStoreTests.swift b/Tests/CodexBarTests/KeychainCacheStoreTests.swift index 0e415239..de474a86 100644 --- a/Tests/CodexBarTests/KeychainCacheStoreTests.swift +++ b/Tests/CodexBarTests/KeychainCacheStoreTests.swift @@ -28,6 +28,16 @@ struct KeychainCacheStoreTests { } } + @Test + func `background interaction keeps real keychain cache available for no UI reads writes and deletes`() { + KeychainAccessGate.withTaskOverrideForTesting(false) { + ProviderInteractionContext.$current.withValue(.background) { + #expect(KeychainCacheStore.canUseRealKeychainForTesting == true) + #expect(KeychainCacheStore.canEnumerateOrDeleteRealKeychainForTesting == true) + } + } + } + @Test func `stores and loads entry`() { KeychainCacheStore.setTestStoreForTesting(true) @@ -146,6 +156,12 @@ struct KeychainCacheStoreTests { } } + @Test + func `delete interaction not allowed is non fatal`() { + let key = KeychainCacheStore.Key(category: "test", identifier: UUID().uuidString) + #expect(KeychainCacheStore.clearResultForKeychainDeleteStatus(errSecInteractionNotAllowed, key: key) == false) + } + @Test func `load failure override bypasses test store without affecting store or clear`() { KeychainCacheStore.setTestStoreForTesting(true) diff --git a/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift new file mode 100644 index 00000000..fb00d1ae --- /dev/null +++ b/Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing + +struct KeychainPromptSafetyAuditTests { + @Test + func `agent instructions forbid keychain prompt validation`() throws { + let agents = try Self.readRepoFile("AGENTS.md") + + #expect(agents.contains("Never run tests/checks or ad-hoc validation that can display macOS Keychain prompts")) + #expect(agents.contains("use parser tests, stubs, test stores, or `KeychainNoUIQuery`")) + } + + @Test + func `live TTY integration tests are opt in`() throws { + let ttyTests = try Self.readRepoFile("Tests/CodexBarTests/TTYIntegrationTests.swift") + + #expect(ttyTests.contains("LIVE_CODEX_TTY")) + #expect(ttyTests.contains("LIVE_CLAUDE_TTY")) + #expect(ttyTests.contains("guard ProcessInfo.processInfo.environment[\"LIVE_CODEX_TTY\"] == \"1\"")) + #expect(ttyTests.contains("guard ProcessInfo.processInfo.environment[\"LIVE_CLAUDE_TTY\"] == \"1\"")) + } + + @Test + func `interactive keychain prompt test paths use test doubles`() throws { + let promptLiteral = "allowKeychainPrompt: true" + let testFiles = try Self.swiftTestFiles(excludingSelf: true) + let promptCallSites = try testFiles.flatMap { file in + try Self.lines(in: file) + .enumerated() + .filter { _, line in line.contains(promptLiteral) } + .map { lineNumber, _ in PromptCallSite(file: file, lineNumber: lineNumber + 1) } + } + + #expect(promptCallSites.isEmpty == false) + for callSite in promptCallSites { + let lines = try Self.lines(in: callSite.file) + let usesScopedKeychainDouble = Self.hasOpenKeychainTestDouble(lines: lines, before: callSite.lineNumber) + let failureMessage = "\(callSite.file.path):\(callSite.lineNumber) has \(promptLiteral) " + + "without an enclosing keychain test double" + #expect(usesScopedKeychainDouble, "\(failureMessage)") + } + } + + @Test + func `tests do not call SecItemCopyMatching except no UI query coverage`() throws { + let offenders = try Self.swiftTestFiles().filter { file in + let text = try Self.readFile(file) + return text.contains("SecItemCopyMatching") + && !file.path.hasSuffix("Tests/CodexBarTests/KeychainNoUIQueryTests.swift") + && !file.path.hasSuffix("Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift") + } + + #expect(offenders.isEmpty, "Unexpected direct SecItemCopyMatching in tests: \(offenders.map(\.path))") + } + + private static func repoRoot() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + } + + private static func readRepoFile(_ relativePath: String) throws -> String { + try self.readFile(self.repoRoot().appendingPathComponent(relativePath)) + } + + private static func readFile(_ url: URL) throws -> String { + try String(contentsOf: url, encoding: .utf8) + } + + private static func lines(in url: URL) throws -> [Substring] { + try self.readFile(url).split(separator: "\n", omittingEmptySubsequences: false) + } + + private static func swiftTestFiles(excludingSelf: Bool = false) throws -> [URL] { + let testsRoot = self.repoRoot().appendingPathComponent("Tests/CodexBarTests", isDirectory: true) + guard let enumerator = FileManager.default.enumerator( + at: testsRoot, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles]) + else { return [] } + + var files: [URL] = [] + for case let file as URL in enumerator where file.pathExtension == "swift" { + let values = try file.resourceValues(forKeys: [.isRegularFileKey]) + if values.isRegularFile == true { + if excludingSelf, file.path.hasSuffix("Tests/CodexBarTests/KeychainPromptSafetyAuditTests.swift") { + continue + } + files.append(file) + } + } + return files + } + + private static func hasOpenKeychainTestDouble(lines: [Substring], before oneBasedLineNumber: Int) -> Bool { + let helperNames = [ + "withClaudeKeychainOverridesForTesting", + "withSecurityCLIReadOverrideForTesting", + "KeychainAccessPreflight.withCheckGenericPasswordOverrideForTesting", + ] + let targetIndex = oneBasedLineNumber - 1 + let lineRange = lines.indices.prefix(through: targetIndex) + return lineRange.contains { index in + helperNames.contains { lines[index].contains($0) } + && self.hasOpenBraceScope(lines: lines, from: index, through: targetIndex) + } + } + + private static func hasOpenBraceScope(lines: [Substring], from startIndex: Int, through endIndex: Int) -> Bool { + var balance = 0 + var sawOpeningBrace = false + for line in lines[startIndex...endIndex] { + for character in line { + switch character { + case "{": + balance += 1 + sawOpeningBrace = true + case "}": + balance -= 1 + default: + continue + } + } + } + return sawOpeningBrace && balance > 0 + } + + private struct PromptCallSite { + let file: URL + let lineNumber: Int + } +} diff --git a/Tests/CodexBarTests/LoginNotificationLogicTests.swift b/Tests/CodexBarTests/LoginNotificationLogicTests.swift new file mode 100644 index 00000000..68f4ab91 --- /dev/null +++ b/Tests/CodexBarTests/LoginNotificationLogicTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import CodexBar + +@Suite(.serialized) +struct LoginNotificationLogicTests { + @Test + func `login success notification copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = LoginNotificationLogic.notificationCopy(providerName: "Codex") + + #expect(copy.title == "Codex 登入成功") + #expect(copy.body == "你可以回到 App;認證已完成。") + } + } + + private static func withAppLanguage(_ language: String, perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue(language, operation: body) + } +} diff --git a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift index 58c27b2f..44ba8a5e 100644 --- a/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift +++ b/Tests/CodexBarTests/ManagedCodexAccountServiceTests.swift @@ -603,7 +603,7 @@ struct ManagedCodexAccountServiceTests { identityReader: StubManagedCodexIdentityReader.emails([]), workspaceResolver: StubManagedCodexWorkspaceResolver()) - await #expect(throws: ManagedCodexAccountServiceError.loginFailed) { + await #expect(throws: ManagedCodexAccountServiceError.self) { try await service.authenticateManagedAccount() } @@ -611,6 +611,36 @@ struct ManagedCodexAccountServiceTests { #expect(store.snapshot.accounts.isEmpty) } + @Test + func `auth failure preserves codex login result output`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + let loginResult = CodexLoginRunner.Result( + outcome: .failed(status: 42), + output: "OAuth callback used the wrong browser profile") + let service = ManagedCodexAccountService( + store: InMemoryManagedCodexAccountStore( + accounts: ManagedCodexAccountSet(version: 1, accounts: [])), + homeFactory: TestManagedCodexHomeFactory(root: root), + loginRunner: StubManagedCodexLoginRunner(result: loginResult), + identityReader: StubManagedCodexIdentityReader.emails([]), + workspaceResolver: StubManagedCodexWorkspaceResolver()) + + do { + _ = try await service.authenticateManagedAccount() + Issue.record("Expected managed Codex login failure") + } catch let error as ManagedCodexAccountServiceError { + guard case let .loginFailed(capturedResult) = error else { + Issue.record("Expected loginFailed, got \(error)") + return + } + #expect(capturedResult == loginResult) + #expect(error.userFacingMessage.contains("codex --version")) + #expect(error.userFacingMessage.contains("OAuth callback used the wrong browser profile")) + } + } + @Test func `remove deletes managed home under managed root`() async throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift index 032617f8..f86d0ee2 100644 --- a/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift +++ b/Tests/CodexBarTests/MenuBarVisibilityWatcherTests.swift @@ -1,3 +1,4 @@ +import CoreGraphics import Foundation import Testing @testable import CodexBar @@ -64,7 +65,53 @@ struct MenuBarVisibilityWatcherTests { } @Test - func `flags visible item attached to a detached screen`() { + func `window probe matches autosave name and reports display bounds`() { + let snapshots = MenuBarStatusItemWindowProbe.snapshots( + matching: ["codexbar-merged"], + windowInfo: [[ + kCGWindowName as String: "codexbar-merged", + kCGWindowOwnerName as String: "Control Center", + kCGWindowIsOnscreen as String: true, + kCGWindowBounds as String: [ + "X": 1680, + "Y": 0, + "Width": 70, + "Height": 24, + ], + ]], + displayBounds: [CGRect(x: 0, y: 0, width: 2056, height: 1329)]) + + #expect(snapshots.count == 1) + #expect(snapshots.first?.name == "codexbar-merged") + #expect(snapshots.first?.ownerName == "Control Center") + #expect(snapshots.first?.isOnscreen == true) + #expect(snapshots.first?.isWithinDisplayBounds == true) + } + + @Test + func `window probe detects offscreen status item by bounds`() { + let snapshots = MenuBarStatusItemWindowProbe.snapshots( + matching: ["codexbar-merged"], + windowInfo: [[ + kCGWindowName as String: "codexbar-merged", + kCGWindowOwnerName as String: "Control Center", + kCGWindowIsOnscreen as String: true, + kCGWindowBounds as String: [ + "X": 2023, + "Y": 0, + "Width": 71, + "Height": 24, + ], + ]], + displayBounds: [CGRect(x: 0, y: 0, width: 2056, height: 1329)]) + + #expect(snapshots.count == 1) + #expect(snapshots.first?.isOnscreen == true) + #expect(snapshots.first?.isWithinDisplayBounds == false) + } + + @Test + func `allows visible item attached to a detached screen`() { let snapshot = StatusItemVisibilitySnapshot( isVisible: true, hasButton: true, @@ -73,7 +120,35 @@ struct MenuBarVisibilityWatcherTests { isOnCurrentScreen: false, buttonWidth: 18) - #expect(MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + } + + @Test + func `classifies detached live item as displaced but not blocked`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + #expect(MenuBarVisibilityWatcher.isDisplacedSnapshot(snapshot: snapshot)) + } + + @Test + func `classifies stale screen live item as displaced but not blocked`() { + let snapshot = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(!MenuBarVisibilityWatcher.isBlockedSnapshot(snapshot: snapshot)) + #expect(MenuBarVisibilityWatcher.isDisplacedSnapshot(snapshot: snapshot)) } @Test @@ -165,7 +240,7 @@ struct MenuBarVisibilityWatcherTests { } @Test - func `screen change recovery triggers when a display is removed with visible status item`() { + func `screen change placement refresh ignores display removal with healthy status item`() { let healthy = StatusItemVisibilitySnapshot( isVisible: true, hasButton: true, @@ -173,14 +248,14 @@ struct MenuBarVisibilityWatcherTests { hasScreen: true, buttonWidth: 18) - #expect(MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( + #expect(!MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( previousScreenCount: 2, currentScreenCount: 1, snapshots: [healthy])) } @Test - func `screen change recovery ignores display removal when no status item is visible`() { + func `screen change placement refresh ignores display removal when no status item is visible`() { let hidden = StatusItemVisibilitySnapshot( isVisible: false, hasButton: true, @@ -188,7 +263,7 @@ struct MenuBarVisibilityWatcherTests { hasScreen: true, buttonWidth: 18) - #expect(!MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( + #expect(!MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( previousScreenCount: 2, currentScreenCount: 1, snapshots: [hidden])) @@ -203,14 +278,43 @@ struct MenuBarVisibilityWatcherTests { hasScreen: false, buttonWidth: 18) - #expect(MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( - previousScreenCount: 1, + #expect(MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery(snapshots: [blocked])) + } + + @Test + func `screen change placement refresh triggers for detached live item after display removal`() { + let displaced = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( + previousScreenCount: 2, currentScreenCount: 1, - snapshots: [blocked])) + snapshots: [displaced])) + } + + @Test + func `screen change placement refresh triggers for stale screen live item after display removal`() { + let displaced = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: true, + hasScreen: true, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( + previousScreenCount: 2, + currentScreenCount: 1, + snapshots: [displaced])) } @Test - func `screen change recovery ignores healthy item when display count does not shrink`() { + func `screen change placement refresh ignores healthy item when display count does not shrink`() { let healthy = StatusItemVisibilitySnapshot( isVisible: true, hasButton: true, @@ -218,30 +322,34 @@ struct MenuBarVisibilityWatcherTests { hasScreen: true, buttonWidth: 18) - #expect(!MenuBarVisibilityWatcher.shouldAttemptScreenChangeRecovery( + #expect(!MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( previousScreenCount: 1, currentScreenCount: 2, snapshots: [healthy])) } @Test - func `screen change retry continues while blocked before retry limit`() { - let blocked = StatusItemVisibilitySnapshot( + func `screen change placement refresh triggers for displaced live item when display count is unchanged`() { + let displaced = StatusItemVisibilitySnapshot( isVisible: true, hasButton: true, hasWindow: true, - hasScreen: false, + hasScreen: true, isOnCurrentScreen: false, buttonWidth: 18) - #expect(MenuBarVisibilityWatcher.shouldRetryScreenChangeRecovery( - attempt: MenuBarVisibilityWatcher.screenChangeRecoveryRetryLimit - 1, - snapshots: [blocked])) + #expect(MenuBarVisibilityWatcher.shouldRefreshScreenChangePlacement( + previousScreenCount: 2, + currentScreenCount: 2, + snapshots: [displaced])) } @Test - func `screen change retry stops at retry limit`() { - let blocked = StatusItemVisibilitySnapshot( + func `manager parked item with live window is not blocked`() { + // A menu bar manager parks items off the active screen with the window intact. + // hasAnyBlockedVisibleSnapshot must return false so verifyScreenChangeRecoveryIfNeeded + // does not trigger repeated recreation that corrupts Control Center. + let managed = StatusItemVisibilitySnapshot( isVisible: true, hasButton: true, hasWindow: true, @@ -249,22 +357,37 @@ struct MenuBarVisibilityWatcherTests { isOnCurrentScreen: false, buttonWidth: 18) - #expect(!MenuBarVisibilityWatcher.shouldRetryScreenChangeRecovery( - attempt: MenuBarVisibilityWatcher.screenChangeRecoveryRetryLimit, - snapshots: [blocked])) + #expect(!MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot([managed])) + #expect(MenuBarVisibilityWatcher.hasAnyDisplacedVisibleSnapshot([managed])) } @Test - func `screen change retry stops when recovered`() { - let healthy = StatusItemVisibilitySnapshot( + func `manager parked item with live window on stale screen is not blocked`() { + let managed = StatusItemVisibilitySnapshot( isVisible: true, hasButton: true, hasWindow: true, hasScreen: true, + isOnCurrentScreen: false, buttonWidth: 18) - #expect(!MenuBarVisibilityWatcher.shouldRetryScreenChangeRecovery( - attempt: 1, - snapshots: [healthy])) + #expect(!MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot([managed])) + #expect(MenuBarVisibilityWatcher.hasAnyDisplacedVisibleSnapshot([managed])) + } + + @Test + func `item without window is blocked regardless of screen state`() { + // A missing window cannot be caused by a manager parking the item; it signals + // a genuine system block and must trigger recovery. + let blocked = StatusItemVisibilitySnapshot( + isVisible: true, + hasButton: true, + hasWindow: false, + hasScreen: false, + isOnCurrentScreen: false, + buttonWidth: 18) + + #expect(MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot([blocked])) + #expect(!MenuBarVisibilityWatcher.hasAnyDisplacedVisibleSnapshot([blocked])) } } diff --git a/Tests/CodexBarTests/MenuCardAntigravityTests.swift b/Tests/CodexBarTests/MenuCardAntigravityTests.swift index 6916e6d3..f2947a61 100644 --- a/Tests/CodexBarTests/MenuCardAntigravityTests.swift +++ b/Tests/CodexBarTests/MenuCardAntigravityTests.swift @@ -111,6 +111,131 @@ struct MenuCardAntigravityTests { #expect(model.metrics[1].resetText != nil) } + @Test + func `antigravity metrics include complete per model quota windows`() throws { + let now = Date(timeIntervalSince1970: 1_735_000_000) + let resetTime = now.addingTimeInterval(3600) + let antigravitySnapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "GPT-OSS 120B (Medium)", + modelId: "MODEL_PLACEHOLDER_M55", + remainingFraction: 0.25, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M53", + remainingFraction: 0.5, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Opus 4.6 (Thinking)", + modelId: "MODEL_PLACEHOLDER_M50", + remainingFraction: 0.75, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "MODEL_PLACEHOLDER_M52", + remainingFraction: 1, + resetTime: resetTime, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: "Pro", + source: .local) + let snapshot = try antigravitySnapshot.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == [ + "Claude", + "Gemini Pro", + "Gemini Flash", + "Claude Opus 4.6 (Thinking)", + "Gemini 3 Pro (High)", + "Gemini 3 Pro (Low)", + "GPT-OSS 120B (Medium)", + ]) + #expect(model.metrics.suffix(4).map(\.percentLabel) == [ + "75% left", + "100% left", + "50% left", + "25% left", + ]) + } + + @Test + func `antigravity per model extra windows still render when optional extras are disabled`() throws { + // Regression: the optional-credits/extra-usage setting is Codex-specific and must NOT hide + // other providers' core extra windows (here Antigravity per-model quotas). + let now = Date(timeIntervalSince1970: 1_735_000_000) + let resetTime = now.addingTimeInterval(3600) + let antigravitySnapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Opus 4.6 (Thinking)", + modelId: "MODEL_PLACEHOLDER_M50", + remainingFraction: 0.75, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro (High)", + modelId: "MODEL_PLACEHOLDER_M52", + remainingFraction: 1, + resetTime: resetTime, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: "Pro") + let snapshot = try antigravitySnapshot.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + // Per-model extra windows remain visible even with optional extras disabled. + #expect(model.metrics.contains { $0.title == "Claude Opus 4.6 (Thinking)" }) + #expect(model.metrics.contains { $0.title == "Gemini 3 Pro (High)" }) + } + @Test func `antigravity missing families show full usage in used mode`() throws { let now = Date() diff --git a/Tests/CodexBarTests/MenuCardCostHintTests.swift b/Tests/CodexBarTests/MenuCardCostHintTests.swift index 27c3fded..086e9c63 100644 --- a/Tests/CodexBarTests/MenuCardCostHintTests.swift +++ b/Tests/CodexBarTests/MenuCardCostHintTests.swift @@ -49,4 +49,48 @@ struct MenuCardCostHintTests { #expect(model.tokenUsage?.hintLine?.contains("cache read/write tokens") == true) #expect(model.tokenUsage?.hintLine?.contains("Claude Code /status") == true) } + + @Test + func `one day history label stays today`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: 120, + sessionCostUSD: 1.2, + last30DaysTokens: 120, + last30DaysCostUSD: 1.2, + historyDays: 1, + daily: [ + .init( + date: "2026-05-14", + inputTokens: 100, + outputTokens: 20, + totalTokens: 120, + costUSD: 1.2, + modelsUsed: ["claude-sonnet-4-6"], + modelBreakdowns: nil), + ], + updatedAt: now) + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: nil, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: snapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.tokenUsage?.monthLine.hasPrefix("Today: ") == true) + } } diff --git a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift index 8c63c506..3a17f9b1 100644 --- a/Tests/CodexBarTests/MenuCardDeepSeekTests.swift +++ b/Tests/CodexBarTests/MenuCardDeepSeekTests.swift @@ -4,6 +4,39 @@ import Testing @testable import CodexBar struct MenuCardDeepSeekTests { + private static func sampleDeepSeekSummary(now: Date = Date()) -> DeepSeekUsageSummary { + DeepSeekUsageSummary( + todayTokens: 123, + currentMonthTokens: 456, + todayCost: 0.0123, + currentMonthCost: 0.0456, + requestCount: 7, + currentMonthRequestCount: 8, + topModel: "deepseek-chat", + categoryBreakdown: [ + DeepSeekCategoryBreakdown(category: .promptCacheHitToken, tokens: 10, cost: 0.001), + DeepSeekCategoryBreakdown(category: .promptCacheMissToken, tokens: 20, cost: 0.002), + DeepSeekCategoryBreakdown(category: .responseToken, tokens: 30, cost: 0.003), + ], + daily: [ + DeepSeekDailyUsage(date: "2026-05-26", totalTokens: 456, cost: 0.0456, requestCount: 8), + ], + currency: "CNY", + updatedAt: now) + } + + private static func makeSnapshot(now: Date, usageSummary: DeepSeekUsageSummary? = nil) -> UsageSnapshot { + DeepSeekUsageSnapshot( + isAvailable: true, + currency: "USD", + totalBalance: 9.32, + grantedBalance: 0, + toppedUpBalance: 9.32, + usageSummary: usageSummary, + updatedAt: now) + .toUsageSnapshot() + } + @Test func `model shows balance as status text instead of percentage detail`() throws { let now = Date() @@ -50,4 +83,64 @@ struct MenuCardDeepSeekTests { #expect(primary.detailText == nil) #expect(primary.resetText == nil) } + + @Test + func `model hides optional deepseek usage when extras disabled`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, usageSummary: Self.sampleDeepSeekSummary(now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.usageNotes.isEmpty) + } + + @Test + func `model shows optional deepseek usage when extras enabled`() throws { + let now = Date() + let metadata = try #require(ProviderDefaults.metadata[.deepseek]) + let snapshot = Self.makeSnapshot(now: now, usageSummary: Self.sampleDeepSeekSummary(now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .deepseek, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.accessibilityLabel == "DeepSeek 30 day token usage trend") + #expect(model.usageNotes.contains { $0.contains("Today:") }) + } } diff --git a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift index 3f9a2f6c..cd6e5941 100644 --- a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift +++ b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift @@ -67,6 +67,198 @@ struct MenuCardModelCodexProjectionTests { #expect(weekly.detailRightText == "Lasts until reset") } + @Test + func `codex weekly lane includes workday markers when workDaysPerWeek is set`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 4, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [], .weekly: []], + workDaysPerWeek: 5, + now: now)) + + let weekly = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(weekly.warningMarkerPercents == [20.0, 40.0, 60.0, 80.0]) + + let session = try #require(model.metrics.first { $0.id == "primary" }) + #expect(session.warningMarkerPercents.isEmpty) + } + + @Test + func `codex weekly lane workday markers merge with quota warning markers`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 4, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [], .weekly: [50]], + workDaysPerWeek: 5, + now: now)) + + let weekly = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(weekly.warningMarkerPercents == [20.0, 40.0, 50.0, 60.0, 80.0]) + } + + @Test + func `codex weekly lane workday markers not inverted by usageBarsShowUsed`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 4, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + quotaWarningThresholds: [.session: [], .weekly: []], + workDaysPerWeek: 5, + now: now)) + + let weekly = try #require(model.metrics.first { $0.id == "secondary" }) + #expect(weekly.warningMarkerPercents == [20.0, 40.0, 60.0, 80.0]) + } + @Test func `codex plan only snapshot shows limits unavailable placeholder`() throws { let now = Date() @@ -414,6 +606,99 @@ struct MenuCardModelCodexProjectionTests { #expect(model.metrics.first?.percent == 75) } + @Test + func `renders codex spark as a named extra metric after the core lanes`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 4, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "codex-spark", + title: "Codex Spark 5-hour", + window: RateWindow( + usedPercent: 30, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil)), + NamedRateWindow( + id: "codex-spark-weekly", + title: "Codex Spark Weekly", + window: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil)), + ], + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let spark = try #require(model.metrics.first { $0.id == "codex-spark" }) + #expect(spark.title == "Codex Spark 5-hour") + #expect(spark.percent == 70) + #expect(spark.percentLabel == "70% left") + #expect(spark.resetText != nil) + let sparkWeekly = try #require(model.metrics.first { $0.id == "codex-spark-weekly" }) + #expect(sparkWeekly.title == "Codex Spark Weekly") + #expect(sparkWeekly.percent == 0) + #expect(sparkWeekly.percentLabel == "0% left") + #expect(sparkWeekly.resetText != nil) + // Spark trails the core session/weekly lanes rather than replacing them. + let sparkIndex = try #require(model.metrics.firstIndex { $0.id == "codex-spark" }) + let sparkWeeklyIndex = try #require(model.metrics.firstIndex { $0.id == "codex-spark-weekly" }) + let sessionIndex = try #require(model.metrics.firstIndex { $0.id == "primary" }) + #expect(sparkIndex > sessionIndex) + #expect(sparkWeeklyIndex > sparkIndex) + } + @Test func `hides codex credits when disabled`() throws { let now = Date() @@ -465,4 +750,85 @@ struct MenuCardModelCodexProjectionTests { #expect(model.creditsText == nil) } + + @Test + func `hides codex spark extra metric when showOptionalCreditsAndExtraUsage is false`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 2, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 4, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + tertiary: nil, + extraRateWindows: [ + NamedRateWindow( + id: "codex-spark", + title: "Codex Spark 5-hour", + window: RateWindow( + usedPercent: 30, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil)), + NamedRateWindow( + id: "codex-spark-weekly", + title: "Codex Spark Weekly", + window: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil)), + ], + updatedAt: now, + identity: identity) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: false, + hidePersonalInfo: false, + now: now)) + + #expect(!model.metrics.contains { $0.id == "codex-spark" }) + #expect(!model.metrics.contains { $0.id == "codex-spark-weekly" }) + #expect(model.metrics.contains { $0.id == "primary" }) + #expect(model.metrics.contains { $0.id == "secondary" }) + } } diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index e1f19b3c..6e64710a 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -59,70 +59,6 @@ struct OverviewMenuCardVisibilityTests { } } -struct OpenAIAPIMenuCardModelTests { - @Test - func `admin usage model shows summaries and spend without fake quota bars`() throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) - let metadata = try #require(ProviderDefaults.metadata[.openai]) - let apiUsage = OpenAIAPIUsageSnapshot( - daily: [ - OpenAIAPIUsageSnapshot.DailyBucket( - day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), - costUSD: 12.5, - requests: 40, - inputTokens: 1000, - cachedInputTokens: 250, - outputTokens: 500, - totalTokens: 1500, - lineItems: [ - OpenAIAPIUsageSnapshot.LineItemBreakdown(name: "Text tokens", costUSD: 12.5), - ], - models: [ - OpenAIAPIUsageSnapshot.ModelBreakdown( - name: "gpt-5.2", - requests: 40, - inputTokens: 1000, - cachedInputTokens: 250, - outputTokens: 500, - totalTokens: 1500), - ]), - ], - updatedAt: now) - - let model = UsageMenuCardView.Model.make(.init( - provider: .openai, - metadata: metadata, - snapshot: apiUsage.toUsageSnapshot(), - credits: nil, - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: nil, plan: nil), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: false, - now: now)) - - #expect(model.metrics.isEmpty) - #expect(model.openAIAPIUsage != nil) - #expect(model.inlineUsageDashboard?.kpis.first?.value == "$12.50") - #expect(model.inlineUsageDashboard?.points.count == 1) - #expect(model.providerCost == nil) - #expect(model.usageNotes.contains { $0.contains("Today: $12.50") }) - #expect(model.usageNotes.contains("Top model: gpt-5.2")) - #expect(model.creditsText == nil) - #expect(model.planText == "Admin API") - } -} - struct ProviderInlineDashboardModelTests { @Test func `claude admin api usage gets inline dashboard`() throws { @@ -349,11 +285,69 @@ struct ProviderInlineDashboardModelTests { hidePersonalInfo: false, now: now)) - #expect(model.inlineUsageDashboard?.kpis.first?.value == "€1.5000") - #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: €1.5000") + #expect(model.inlineUsageDashboard?.kpis.first?.value == "€1.50") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: €1.50") #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: mistral-large") == true) } + @Test + func `mistral billing usage can show cost card summary`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.mistral]) + let snapshot = MistralUsageSnapshot( + totalCost: 1.5, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 100, + totalOutputTokens: 50, + totalCachedTokens: 25, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 1.5, + inputTokens: 100, + cachedTokens: 25, + outputTokens: 50, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-large", + cost: 1.5, + inputTokens: 100, + cachedTokens: 25, + outputTokens: 50), + ]), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .mistral, + metadata: metadata, + snapshot: snapshot.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: snapshot.toCostUsageTokenSnapshot(historyDays: 30), + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(ProviderDescriptorRegistry.descriptor(for: .mistral).tokenCost.supportsTokenCost) + #expect(model.tokenUsage?.sessionLine == "Latest billing day (Nov 14): €1.50 · 175 tokens") + #expect(model.tokenUsage?.monthLine == "This month: €1.50 · 175 tokens") + #expect(model.tokenUsage?.hintLine == "Reported by Mistral billing usage.") + } + @Test func `zai hourly usage gets inline dashboard`() throws { let now = try #require(Self.zaiDate("2023-11-15 12:00")) @@ -835,7 +829,7 @@ struct MenuCardModelTests { } @Test - func `claude model includes design and routines bars when present`() throws { + func `claude model includes routines bar when present`() throws { let now = Date() let identity = ProviderIdentitySnapshot( providerID: .claude, @@ -859,14 +853,6 @@ struct MenuCardModelTests { resetsAt: now.addingTimeInterval(7800), resetDescription: nil), extraRateWindows: [ - NamedRateWindow( - id: "claude-design", - title: "Designs", - window: RateWindow( - usedPercent: 31, - windowMinutes: 10080, - resetsAt: now.addingTimeInterval(8200), - resetDescription: nil)), NamedRateWindow( id: "claude-routines", title: "Daily Routines", @@ -899,7 +885,7 @@ struct MenuCardModelTests { hidePersonalInfo: false, now: now)) - #expect(model.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Designs", "Daily Routines"]) + #expect(model.metrics.map(\.title) == ["Session", "Weekly", "Sonnet", "Daily Routines"]) } @Test diff --git a/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift index f3bac6a6..58c3c28a 100644 --- a/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift +++ b/Tests/CodexBarTests/MenuCardQuotaWarningMarkerTests.swift @@ -4,6 +4,19 @@ import Testing @testable import CodexBar struct MenuCardQuotaWarningMarkerTests { + @Test + func `quota warning marker geometry is inset and hairline`() { + let rect = UsageProgressBar.warningMarkerRect( + x: 50, + size: CGSize(width: 100, height: 6), + scale: 2) + + #expect(rect.width == 1) + #expect(rect.height < 6) + #expect(rect.minY > 0) + #expect(abs(rect.midX - 50) <= 0.5) + } + @Test func `omits quota warning markers for disabled windows`() throws { let now = Date() @@ -65,4 +78,46 @@ struct MenuCardQuotaWarningMarkerTests { #expect(model.metrics.first?.warningMarkerPercents == [50]) #expect(model.metrics[1].warningMarkerPercents.isEmpty) } + + @Test + func `work day marker percents for 5-day week`() { + #expect(workDayMarkerPercents(workDays: 5, windowMinutes: 10080) == [20.0, 40.0, 60.0, 80.0]) + } + + @Test + func `work day marker percents for 4-day week`() { + #expect(workDayMarkerPercents(workDays: 4, windowMinutes: 10080) == [25.0, 50.0, 75.0]) + } + + @Test + func `work day marker percents for 7-day week`() { + let markers = workDayMarkerPercents(workDays: 7, windowMinutes: 10080) + #expect(markers.count == 6) + #expect(abs(markers[0] - 14.2857) < 0.001) + #expect(abs(markers[5] - 85.7143) < 0.001) + } + + @Test + func `work day marker percents nil work days returns empty`() { + #expect(workDayMarkerPercents(workDays: nil, windowMinutes: 10080).isEmpty) + } + + @Test + func `work day marker percents nil window minutes returns empty`() { + #expect(workDayMarkerPercents(workDays: 5, windowMinutes: nil).isEmpty) + } + + @Test + func `work day marker percents non-weekly window returns empty`() { + #expect(workDayMarkerPercents(workDays: 5, windowMinutes: 300).isEmpty) + #expect(workDayMarkerPercents(workDays: 5, windowMinutes: 1440).isEmpty) + } + + @Test + func `work day marker percents invalid work days returns empty`() { + #expect(workDayMarkerPercents(workDays: 1, windowMinutes: 10080).isEmpty) + #expect(workDayMarkerPercents(workDays: 0, windowMinutes: 10080).isEmpty) + #expect(workDayMarkerPercents(workDays: 8, windowMinutes: 10080).isEmpty) + #expect(workDayMarkerPercents(workDays: -1, windowMinutes: 10080).isEmpty) + } } diff --git a/Tests/CodexBarTests/MiMoProviderTests.swift b/Tests/CodexBarTests/MiMoProviderTests.swift index 6dea64ee..411ed124 100644 --- a/Tests/CodexBarTests/MiMoProviderTests.swift +++ b/Tests/CodexBarTests/MiMoProviderTests.swift @@ -378,10 +378,12 @@ struct MiMoProviderTests { @Test @MainActor func `provider detail plan row formats mimo as balance`() { - let row = ProviderDetailView.planRow(provider: .mimo, planText: "Balance: $25.51") + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + let row = ProviderDetailView.planRow(provider: .mimo, planText: "Balance: $25.51") - #expect(row?.label == "Balance") - #expect(row?.value == "$25.51") + #expect(row?.label == "Balance") + #expect(row?.value == "$25.51") + } } @Test(arguments: [UsageProvider.openrouter, .mimo]) diff --git a/Tests/CodexBarTests/MiniMaxLogRedactorTests.swift b/Tests/CodexBarTests/MiniMaxLogRedactorTests.swift new file mode 100644 index 00000000..d37ed225 --- /dev/null +++ b/Tests/CodexBarTests/MiniMaxLogRedactorTests.swift @@ -0,0 +1,105 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite(.serialized) +struct MiniMaxLogRedactorTests { + private static var miniMaxCpPlaceholder: String { + ["sk", "cp", "placeholder"].joined(separator: "-") + } + + private static var miniMaxApiPlaceholder: String { + ["sk", "api", "placeholder"].joined(separator: "-") + } + + @Test + func `sk-cp token is redacted`() { + let input = Self.miniMaxCpPlaceholder + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("sk-cp-") == false) + #expect(redacted.contains("")) + #expect(redacted.contains("placeholder") == false) + } + + @Test + func `sk-api token is redacted`() { + let input = Self.miniMaxApiPlaceholder + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("sk-api-") == false) + #expect(redacted.contains("")) + #expect(redacted.contains("placeholder") == false) + } + + @Test + func `cookie header is redacted`() { + let input = "Cookie: session=cookie-session-placeholder; token=\(Self.miniMaxCpPlaceholder)" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("session=cookie-session-placeholder") == false) + #expect(redacted.contains(Self.miniMaxCpPlaceholder) == false) + #expect(redacted.contains("Cookie: ")) + } + + @Test + func `authorization header value is redacted`() { + // Short obvious placeholder, not JWT-like + let input = "Authorization: Bearer fake-bearer-token" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("fake-bearer-token") == false) + #expect(redacted.contains("Authorization:")) + } + + @Test + func `bearer token is not present in raw form`() { + let input = "Authorization: bearer \(Self.miniMaxApiPlaceholder)" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains(Self.miniMaxApiPlaceholder) == false) + } + + @Test + func `email is redacted`() { + let input = "Contact: user@example.com" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("user@example.com") == false) + #expect(redacted.contains("")) + } + + @Test + func `minimax token in cookie is not present in raw form`() { + let input = "Cookie: session=session-placeholder; token=\(Self.miniMaxCpPlaceholder)" + let redacted = LogRedactor.redact(input) + #expect(redacted.contains("session=session-placeholder") == false) + #expect(redacted.contains(Self.miniMaxCpPlaceholder) == false) + } + + @Test + func `redacted text no longer matches original token pattern`() { + let originalToken = Self.miniMaxCpPlaceholder + let input = "Token: \(originalToken)" + let redacted = LogRedactor.redact(input) + + #expect(redacted.contains(originalToken) == false) + #expect(redacted.contains("")) + } + + @Test + func `minimax token with punctuation suffix is fully redacted`() { + let punctuatedToken = "\(Self.miniMaxApiPlaceholder).suffix-more" + let input = "Error: token=\(punctuatedToken)" + let redacted = LogRedactor.redact(input) + + #expect(redacted.contains("sk-api-") == false) + #expect(redacted.contains("suffix-more") == false) + #expect(redacted.contains("")) + } + + @Test + func `authorization header minimax token leaves no suffix fragment`() { + let punctuatedToken = "\(Self.miniMaxCpPlaceholder)-part.two" + let input = "Authorization: Bearer \(punctuatedToken)" + let redacted = LogRedactor.redact(input) + + #expect(redacted.contains("sk-cp-") == false) + #expect(redacted.contains("part.two") == false) + #expect(redacted.contains("Authorization: ")) + } +} diff --git a/Tests/CodexBarTests/MistralUsageParserTests.swift b/Tests/CodexBarTests/MistralUsageParserTests.swift index 6189b54b..b3801753 100644 --- a/Tests/CodexBarTests/MistralUsageParserTests.swift +++ b/Tests/CodexBarTests/MistralUsageParserTests.swift @@ -110,13 +110,8 @@ struct MistralUsageParserTests { #expect(snapshot.startDate != nil) #expect(snapshot.endDate != nil) - // Use UTC so the test matches the JSON fixture's start_date - // ("2025-11-01T00:00:00Z") regardless of which timezone the test - // runner is in. Original test used `Calendar.current` which - // converts UTC midnight to local time; on Pacific it lands at - // 2025-10-31 17:00 PDT and the month component returns 10. var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = TimeZone(identifier: "UTC") ?? .gmt + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) if let start = snapshot.startDate { #expect(calendar.component(.month, from: start) == 11) #expect(calendar.component(.year, from: start) == 2025) @@ -172,6 +167,138 @@ struct MistralUsageSnapshotConversionTests { #expect(usage.primary == nil) #expect(usage.identity?.loginMethod == "API spend: $0.0000 this month") } + + @Test + func `converts billing usage into cost token snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let snapshot = MistralUsageSnapshot( + totalCost: 1.75, + currency: "eur", + currencySymbol: "€", + totalInputTokens: 300, + totalOutputTokens: 150, + totalCachedTokens: 50, + modelCount: 2, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 1.5, + inputTokens: 100, + cachedTokens: 20, + outputTokens: 50, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-large", + cost: 1.5, + inputTokens: 100, + cachedTokens: 20, + outputTokens: 50), + ]), + MistralDailyUsageBucket( + day: "2023-11-15", + cost: 0.25, + inputTokens: 200, + cachedTokens: 30, + outputTokens: 100, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-small", + cost: 0.25, + inputTokens: 200, + cachedTokens: 30, + outputTokens: 100), + ]), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let cost = snapshot.toCostUsageTokenSnapshot(historyDays: 1) + #expect(cost.currencyCode == "EUR") + #expect(cost.historyLabel == "This month") + #expect(cost.historyDays == 2) + #expect(cost.sessionCostUSD == 0.25) + #expect(cost.sessionTokens == 330) + #expect(cost.last30DaysCostUSD == 1.75) + #expect(cost.last30DaysTokens == 500) + #expect(cost.daily.count == 2) + #expect(cost.daily.last?.modelsUsed == ["mistral-small"]) + } + + @Test + func `clamps negative billing adjustments in cost token snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let snapshot = MistralUsageSnapshot( + totalCost: -2, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 100, + totalOutputTokens: 25, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: -1.5, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 25, + models: [ + MistralDailyUsageBucket.ModelBreakdown( + name: "mistral-large", + cost: -1.5, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 25), + ]), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.sessionCostUSD == 0) + #expect(cost.last30DaysCostUSD == 0) + #expect(cost.daily.first?.costUSD == 0) + #expect(cost.daily.first?.modelBreakdowns?.first?.costUSD == 0) + } + + @Test + func `preserves net monthly cost when billing includes credits`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let snapshot = MistralUsageSnapshot( + totalCost: 8, + currency: "EUR", + currencySymbol: "€", + totalInputTokens: 100, + totalOutputTokens: 25, + totalCachedTokens: 0, + modelCount: 1, + daily: [ + MistralDailyUsageBucket( + day: "2023-11-14", + cost: 10, + inputTokens: 100, + cachedTokens: 0, + outputTokens: 25, + models: []), + MistralDailyUsageBucket( + day: "2023-11-15", + cost: -2, + inputTokens: 0, + cachedTokens: 0, + outputTokens: 0, + models: []), + ], + startDate: nil, + endDate: nil, + updatedAt: now) + + let cost = snapshot.toCostUsageTokenSnapshot() + #expect(cost.last30DaysCostUSD == 8) + #expect(cost.sessionCostUSD == 0) + #expect(cost.daily.map(\.costUSD) == [10, 0]) + } } struct MistralStrategyTests { @@ -268,5 +395,6 @@ struct MistralStrategyTests { #expect(descriptor.cli.name == "mistral") #expect(descriptor.fetchPlan.sourceModes == [.auto, .web]) #expect(descriptor.branding.iconResourceName == "ProviderIcon-mistral") + #expect(descriptor.tokenCost.supportsTokenCost) } } diff --git a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift index e7dde89f..e25283f5 100644 --- a/Tests/CodexBarTests/OllamaUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OllamaUsageFetcherTests.swift @@ -17,6 +17,13 @@ struct OllamaUsageFetcherTests { #expect(!OllamaUsageFetcher.shouldAttachCookie(to: nil)) } + @Test + func `rejects non https ollama urls`() { + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://ollama.com/settings"))) + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://www.ollama.com"))) + #expect(!OllamaUsageFetcher.shouldAttachCookie(to: URL(string: "http://app.ollama.com/path"))) + } + @Test func `manual mode without valid header throws no session cookie`() { do { diff --git a/Tests/CodexBarTests/OllamaUsageParserTests.swift b/Tests/CodexBarTests/OllamaUsageParserTests.swift index 4543dee3..bdec9260 100644 --- a/Tests/CodexBarTests/OllamaUsageParserTests.swift +++ b/Tests/CodexBarTests/OllamaUsageParserTests.swift @@ -43,6 +43,8 @@ struct OllamaUsageParserTests { let usage = snapshot.toUsageSnapshot() #expect(usage.identity?.loginMethod == "free") #expect(usage.identity?.accountEmail == "user@example.com") + #expect(usage.primary?.windowMinutes == 5 * 60) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) } @Test @@ -153,6 +155,35 @@ struct OllamaUsageParserTests { #expect(snapshot.sessionUsedPercent == 2.5) #expect(snapshot.weeklyUsedPercent == 4.2) + + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.windowMinutes == nil) + #expect(usage.secondary?.windowMinutes == 7 * 24 * 60) + } + + @Test + func `weekly usage parser finds reset timestamp in long usage block`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let filler = String(repeating: "", count: 40) + let html = """ +
+ Session usage + 0.1% used + Weekly usage + 0.7% used + \(filler) +
Resets in 2 days
+
+ """ + + let snapshot = try OllamaUsageParser.parse(html: html, now: now) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let expectedWeekly = formatter.date(from: "2026-02-02T00:00:00Z") + let usage = snapshot.toUsageSnapshot() + #expect(usage.primary?.resetsAt == nil) + #expect(usage.secondary?.resetsAt == expectedWeekly) } @Test diff --git a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift index 083ac122..88860dd5 100644 --- a/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift +++ b/Tests/CodexBarTests/OpenAIAPICreditBalanceTests.swift @@ -3,9 +3,21 @@ import Testing @testable import CodexBarCore struct OpenAIAPICreditBalanceTests { - private func makeContext(apiKey: String = "sk-test", historyDays: Int = 30) -> ProviderFetchContext { + private func makeContext( + apiKey: String = "sk-test", + usesAdminKey: Bool = false, + projectID: String? = nil, + selectedTokenAccountID: UUID? = nil, + historyDays: Int = 30) -> ProviderFetchContext + { let browserDetection = BrowserDetection(cacheTTL: 0) - let env = ["OPENAI_API_KEY": apiKey] + let apiKeyEnvironmentKey = usesAdminKey + ? OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey + : OpenAIAPISettingsReader.apiKeyEnvironmentKey + var env = [apiKeyEnvironmentKey: apiKey] + if let projectID { + env[OpenAIAPISettingsReader.projectIDEnvironmentKey] = projectID + } return ProviderFetchContext( runtime: .app, sourceMode: .api, @@ -18,6 +30,7 @@ struct OpenAIAPICreditBalanceTests { fetcher: UsageFetcher(environment: env), claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), browserDetection: browserDetection, + selectedTokenAccountID: selectedTokenAccountID, costUsageHistoryDays: historyDays) } @@ -103,6 +116,62 @@ struct OpenAIAPICreditBalanceTests { #expect(result.usage.identity?.loginMethod == "API balance: $75.00") } + @Test + func `legacy API key without project ID falls back to legacy billing`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == nil) + #expect(historyDays == 30) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "sk-test") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(self.makeContext()) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + #expect(result.usage.identity?.accountOrganization == nil) + } + + @Test + func `selected token account uses scrubbed final environment for legacy fallback`() async throws { + let accountID = UUID() + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "account-token") + #expect(credential.projectID == nil) + #expect(historyDays == 30) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "account-token") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(self.makeContext( + apiKey: "account-token", + usesAdminKey: true, + selectedTokenAccountID: accountID)) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + #expect(result.usage.identity?.accountOrganization == nil) + } + @Test func `preserves admin usage error when legacy fallback also fails`() async { let usageFailure = OpenAIAPIUsageError.parseFailed(endpoint: "costs", message: "changed") @@ -120,185 +189,12 @@ struct OpenAIAPICreditBalanceTests { } } - @Test - func `parses admin costs and completions usage into daily summaries`() throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) - let costs = """ - { - "object": "page", - "data": [ - { - "object": "bucket", - "start_time": 1700000000, - "end_time": 1700086400, - "results": [ - { - "object": "organization.costs.result", - "amount": { "value": 12.50, "currency": "usd" }, - "line_item": "Text tokens" - }, - { - "object": "organization.costs.result", - "amount": { "value": "2.25", "currency": "usd" }, - "line_item": "Web search tool calls" - } - ] - }, - { - "object": "bucket", - "start_time": 1700086400, - "end_time": 1700172800, - "results": [ - { - "object": "organization.costs.result", - "amount": { "value": 4.00, "currency": "usd" }, - "line_item": "Text tokens" - } - ] - } - ], - "has_more": false, - "next_page": null - } - """ - let completions = """ - { - "object": "page", - "data": [ - { - "object": "bucket", - "start_time": 1700000000, - "end_time": 1700086400, - "results": [ - { - "object": "organization.usage.completions.result", - "input_tokens": 1000, - "input_cached_tokens": 250, - "output_tokens": 500, - "num_model_requests": 7, - "model": "gpt-5.2" - }, - { - "object": "organization.usage.completions.result", - "input_tokens": 300, - "output_tokens": 200, - "num_model_requests": 3, - "model": "gpt-5.2-codex" - } - ] - }, - { - "object": "bucket", - "start_time": 1700086400, - "end_time": 1700172800, - "results": [ - { - "object": "organization.usage.completions.result", - "input_tokens": 200, - "output_tokens": 100, - "num_model_requests": 2, - "model": "gpt-5.2" - } - ] - } - ], - "has_more": false, - "next_page": null - } - """ - - let snapshot = try OpenAIAPIUsageFetcher._parseSnapshotForTesting( - costs: Data(costs.utf8), - completions: Data(completions.utf8), - now: now, - historyDays: 90) - - #expect(snapshot.historyDays == 90) - #expect(snapshot.historyWindowLabel == "90d") - #expect(snapshot.daily.count == 2) - #expect(snapshot.daily[0].costUSD == 14.75) - #expect(snapshot.daily[0].requests == 10) - #expect(snapshot.daily[0].totalTokens == 2000) - #expect(snapshot.daily[0].cachedInputTokens == 250) - #expect(snapshot.daily[0].lineItems.first?.name == "Text tokens") - #expect(snapshot.last30Days.costUSD == 18.75) - #expect(snapshot.last30Days.requests == 12) - #expect(snapshot.last30Days.totalTokens == 2300) - #expect(snapshot.topModels.first?.name == "gpt-5.2") - #expect(snapshot.topModels.first?.totalTokens == 1800) - } - - @Test - func `admin usage fetch pages long history within endpoint bucket limit`() async throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) - let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) - let transport = ProviderHTTPTransportStub { request in - let response = try HTTPURLResponse( - url: #require(request.url), - statusCode: 200, - httpVersion: nil, - headerFields: nil)! - return (emptyPage, response) - } - - let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( - apiKey: "sk-test", - costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), - completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), - session: transport, - now: now, - historyDays: 90) - - let requests = await transport.requests() - let limits = requests.compactMap { request -> Int? in - guard let url = request.url, - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let raw = components.queryItems?.first(where: { $0.name == "limit" })?.value - else { return nil } - return Int(raw) - } - - #expect(snapshot.historyDays == 90) - #expect(requests.count == 6) - #expect(limits == [31, 31, 28, 31, 31, 28]) - #expect(limits.allSatisfy { $0 <= 31 }) - } - - @Test - func `maps admin usage to openai usage snapshot`() { - let now = Date(timeIntervalSince1970: 1_700_179_200) - let apiUsage = OpenAIAPIUsageSnapshot( - daily: [ - OpenAIAPIUsageSnapshot.DailyBucket( - day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), - costUSD: 8.5, - requests: 42, - inputTokens: 1000, - cachedInputTokens: 400, - outputTokens: 250, - totalTokens: 1250, - lineItems: [], - models: []), - ], - updatedAt: now) - - let usage = apiUsage.toUsageSnapshot() - - #expect(usage.primary == nil) - #expect(usage.providerCost?.used == 8.5) - #expect(usage.providerCost?.limit == 0) - #expect(usage.providerCost?.period == "Last 30 days") - #expect(usage.openAIAPIUsage?.last30Days.requests == 42) - #expect(usage.identity?.loginMethod == "Admin API") - } - @Test func `falls back to credit balance when admin usage endpoint is unavailable`() async throws { let strategy = OpenAIAPIBalanceFetchStrategy( - usageFetcher: { apiKey, historyDays in - #expect(apiKey == "sk-test") + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == nil) #expect(historyDays == 90) throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 500) }, diff --git a/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift new file mode 100644 index 00000000..b13eabe4 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift @@ -0,0 +1,166 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct OpenAIAPIMenuCardModelTests { + @Test + func `admin usage model shows summaries and spend without fake quota bars`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openai]) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 12.5, + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500, + lineItems: [ + OpenAIAPIUsageSnapshot.LineItemBreakdown(name: "Text tokens", costUSD: 12.5), + ], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2", + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500), + ]), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openai, + metadata: metadata, + snapshot: apiUsage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.openAIAPIUsage != nil) + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$12.50") + #expect(model.inlineUsageDashboard?.kpis.last?.title == "Requests") + #expect(model.inlineUsageDashboard?.kpis.last?.value == "40") + #expect(model.inlineUsageDashboard?.points.count == 1) + #expect(model.inlineUsageDashboard?.detailLines.contains("30d requests: 40 requests") == true) + #expect(model.providerCost == nil) + #expect(model.usageNotes.contains { $0.contains("Today: $12.50") }) + #expect(model.usageNotes.contains("Top model: gpt-5.2")) + #expect(model.creditsText == nil) + #expect(model.planText == "Admin API") + } + + @Test + func `admin usage dashboard ignores stale token snapshot after fallback refresh`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openai]) + let staleTokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 1500, + sessionCostUSD: 12.5, + last30DaysTokens: 1500, + last30DaysCostUSD: 12.5, + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-14", + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + costUSD: 12.5, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openai, + metadata: metadata, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: staleTokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard == nil) + #expect(model.tokenUsage == nil) + } + + @Test + func `admin usage model can show cost card summary`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openai]) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 12.5, + requests: 40, + inputTokens: 1000, + cachedInputTokens: 250, + outputTokens: 500, + totalTokens: 1500, + lineItems: [], + models: []), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openai, + metadata: metadata, + snapshot: apiUsage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: apiUsage.toCostUsageTokenSnapshot(), + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(ProviderDescriptorRegistry.descriptor(for: .openai).tokenCost.supportsTokenCost) + #expect(model.tokenUsage?.sessionLine == "Today: $12.50 · 1.5K tokens") + #expect(model.tokenUsage?.monthLine == "Last 30 days: $12.50 · 1.5K tokens") + #expect(model.tokenUsage?.hintLine == "Reported by OpenAI Admin API organization usage.") + } +} diff --git a/Tests/CodexBarTests/OpenAIAPIProjectScopeTests.swift b/Tests/CodexBarTests/OpenAIAPIProjectScopeTests.swift new file mode 100644 index 00000000..a8a0f3f5 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIProjectScopeTests.swift @@ -0,0 +1,334 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCLI +@testable import CodexBarCore + +@Suite(.serialized) +struct OpenAIAPIProjectScopeTests { + @Test + @MainActor + func `token account strips configured project in app environment builder`() { + let settings = Self.makeSettingsStore(suite: "OpenAIAPIProjectScopeTests-app") + settings.openAIAPIKey = "config-token" + settings.openAIAPIProjectID = "proj_config" + settings.addTokenAccount(provider: .openai, label: "Configured account", token: "first-account-token") + settings.addTokenAccount(provider: .openai, label: "Selected account", token: "selected-account-token") + let selectedAccount = settings.tokenAccounts(for: .openai)[1] + + let env = ProviderRegistry.makeEnvironment( + base: [OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env"], + provider: .openai, + settings: settings, + tokenOverride: TokenAccountOverride(provider: .openai, account: selectedAccount)) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "selected-account-token") + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "config-token") + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "first-account-token") + #expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == nil) + } + + @Test + func `token account strips configured project in CLI environment builder`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "Project account", + token: "account-token", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + let accounts = ProviderTokenAccountData(version: 1, accounts: [account], activeIndex: 0) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .openai, + apiKey: "config-token", + workspaceID: "proj_config", + tokenAccounts: accounts), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + + let env = tokenContext.environment( + base: [OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env"], + provider: .openai, + account: account) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "account-token") + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] != "config-token") + #expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == nil) + } + + @Test + @MainActor + func `configured app project scopes admin usage strategy`() async throws { + let settings = Self.makeSettingsStore(suite: "OpenAIAPIProjectScopeTests-configured-project") + settings.openAIAPIKey = "config-token" + settings.openAIAPIProjectID = "proj_config" + let env = ProviderRegistry.makeEnvironment( + base: [:], + provider: .openai, + settings: settings, + tokenOverride: nil) + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "config-token") + #expect(credential.projectID == "proj_config") + #expect(historyDays == 30) + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Configured project usage should not fetch legacy organization balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext(env: env)) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_config") + #expect(result.usage.identity?.accountOrganization == "Project: proj_config") + } + + @Test + func `legacy API key environment can scope admin usage by project`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-admin-legacy") + #expect(credential.projectID == "proj_legacy") + #expect(credential.usesAdminKey == false) + #expect(historyDays == 30) + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Legacy OPENAI_API_KEY project usage should not fetch unscoped balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.apiKeyEnvironmentKey: "sk-admin-legacy", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_legacy", + ])) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_legacy") + #expect(result.usage.identity?.accountOrganization == "Project: proj_legacy") + } + + @Test + func `ambient project with legacy API key preserves billing fallback`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-ambient") + #expect(credential.projectID == "proj_ambient") + #expect(credential.usesAdminKey == false) + #expect(historyDays == 30) + throw OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + }, + balanceFetcher: { apiKey in + #expect(apiKey == "sk-ambient") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.apiKeyEnvironmentKey: "sk-ambient", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_ambient", + ])) + + #expect(result.sourceLabel == "billing-api") + #expect(result.usage.identity?.loginMethod == "API balance: $75.00") + #expect(result.usage.identity?.accountOrganization == nil) + } + + @Test + func `project filtered admin usage does not fall back on service failure`() async { + let usageFailure = OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 500) + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_abc") + #expect(credential.usesAdminKey == true) + #expect(historyDays == 30) + throw usageFailure + }, + balanceFetcher: { _ in + Issue.record("Project-filtered usage must not fall back to organization balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + do { + _ = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc", + ])) + Issue.record("Expected project-filtered admin usage failure.") + } catch let error as OpenAIAPIUsageError { + #expect(error == usageFailure) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error)") + } + } + + @Test + func `project filtered admin usage does not fall back on credential rejection`() async { + let usageFailure = OpenAIAPIUsageError.apiError(endpoint: "costs", statusCode: 403) + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_abc") + #expect(historyDays == 30) + throw usageFailure + }, + balanceFetcher: { _ in + Issue.record("Project-filtered usage must fail closed instead of showing unscoped balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + do { + _ = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc", + ])) + Issue.record("Expected project-filtered admin credential failure.") + } catch let error as OpenAIAPIUsageError { + #expect(error == usageFailure) + } catch { + Issue.record("Expected OpenAIAPIUsageError, got \(error)") + } + } + + @Test + func `project filtered admin usage reports project source label`() async throws { + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, _ in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_abc") + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Project-filtered usage should not fetch legacy balance after admin usage succeeds.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_abc", + ])) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_abc") + #expect(result.usage.identity?.accountOrganization == "Project: proj_abc") + } + + @Test + func `project scope follows final environment even when selected account flag is present`() async throws { + let accountID = UUID() + let strategy = OpenAIAPIBalanceFetchStrategy( + usageFetcher: { credential, historyDays in + #expect(credential.apiKey == "sk-test") + #expect(credential.projectID == "proj_env") + #expect(historyDays == 30) + return OpenAIAPIUsageSnapshot( + daily: [], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + projectID: credential.projectID) + }, + balanceFetcher: { _ in + Issue.record("Final project-scoped environments should not fetch legacy balance.") + return OpenAIAPICreditBalanceSnapshot( + totalGranted: 100, + totalUsed: 25, + totalAvailable: 75, + nextGrantExpiry: nil, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + }) + + let result = try await strategy.fetch(Self.makeContext( + env: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "sk-test", + OpenAIAPISettingsReader.projectIDEnvironmentKey: "proj_env", + ], + selectedTokenAccountID: accountID)) + + #expect(result.sourceLabel == "admin-api:project") + #expect(result.usage.identity?.loginMethod == "Admin API: proj_env") + #expect(result.usage.identity?.accountOrganization == "Project: proj_env") + } + + private static func makeContext( + env: [String: String], + selectedTokenAccountID: UUID? = nil, + historyDays: Int = 30) -> ProviderFetchContext + { + let browserDetection = BrowserDetection(cacheTTL: 0) + return ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection), + browserDetection: browserDetection, + selectedTokenAccountID: selectedTokenAccountID, + costUsageHistoryDays: historyDays) + } + + @MainActor + private static func makeSettingsStore(suite: String) -> SettingsStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + } +} diff --git a/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift b/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift new file mode 100644 index 00000000..002d2bd9 --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIStatusMenuTests.swift @@ -0,0 +1,53 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +extension StatusMenuTests { + @Test + func `open AI API usage submenu ignores stale token snapshot without current admin usage`() throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.selectedMenuProvider = .openai + + let registry = ProviderRegistry.shared + let metadata = try #require(registry.metadata[.openai]) + settings.setProviderEnabled(provider: .openai, metadata: metadata, enabled: true) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let now = Date(timeIntervalSince1970: 1_700_000_000) + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: now), + provider: .openai) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now), provider: .openai) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.makeOpenAIAPIUsageSubmenu(provider: .openai) == nil) + } +} diff --git a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift new file mode 100644 index 00000000..df6f513b --- /dev/null +++ b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift @@ -0,0 +1,376 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenAIAPIUsageFetcherTests { + @Test + func `parses admin costs and completions usage into daily summaries`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let costs = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.costs.result", + "amount": { "value": 12.50, "currency": "usd" }, + "line_item": "Text tokens" + }, + { + "object": "organization.costs.result", + "amount": { "value": "2.25", "currency": "usd" }, + "line_item": "Web search tool calls" + } + ] + }, + { + "object": "bucket", + "start_time": 1700086400, + "end_time": 1700172800, + "results": [ + { + "object": "organization.costs.result", + "amount": { "value": 4.00, "currency": "usd" }, + "line_item": "Text tokens" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ + let completions = """ + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 1000, + "input_cached_tokens": 250, + "output_tokens": 500, + "num_model_requests": 7, + "model": "gpt-5.2" + }, + { + "object": "organization.usage.completions.result", + "input_tokens": 300, + "output_tokens": 200, + "num_model_requests": 3, + "model": "gpt-5.2-codex" + } + ] + }, + { + "object": "bucket", + "start_time": 1700086400, + "end_time": 1700172800, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 200, + "output_tokens": 100, + "num_model_requests": 2, + "model": "gpt-5.2" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """ + + let snapshot = try OpenAIAPIUsageFetcher._parseSnapshotForTesting( + costs: Data(costs.utf8), + completions: Data(completions.utf8), + now: now, + historyDays: 90) + + #expect(snapshot.historyDays == 90) + #expect(snapshot.historyWindowLabel == "90d") + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily[0].costUSD == 14.75) + #expect(snapshot.daily[0].requests == 10) + #expect(snapshot.daily[0].totalTokens == 2000) + #expect(snapshot.daily[0].cachedInputTokens == 250) + #expect(snapshot.daily[0].lineItems.first?.name == "Text tokens") + #expect(snapshot.last30Days.costUSD == 18.75) + #expect(snapshot.last30Days.requests == 12) + #expect(snapshot.last30Days.totalTokens == 2300) + #expect(snapshot.topModels.first?.name == "gpt-5.2") + #expect(snapshot.topModels.first?.totalTokens == 1800) + } + + @Test + func `admin usage fetch pages long history within endpoint bucket limit`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (emptyPage, response) + } + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 90) + + let requests = await transport.requests() + let limits = requests.compactMap { request -> Int? in + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let raw = components.queryItems?.first(where: { $0.name == "limit" })?.value + else { return nil } + return Int(raw) + } + + #expect(snapshot.historyDays == 90) + #expect(requests.count == 6) + #expect(limits == [31, 31, 28, 31, 31, 28]) + #expect(limits.allSatisfy { $0 <= 31 }) + } + + @Test + func `admin usage filters costs and completions by project`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) + let transport = ProviderHTTPTransportStub { request in + let response = try HTTPURLResponse( + url: #require(request.url), + statusCode: 200, + httpVersion: nil, + headerFields: nil)! + return (emptyPage, response) + } + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + projectID: " proj_abc ", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 1) + + let requests = await transport.requests() + let projectIDs = requests.compactMap { request -> String? in + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + return components.queryItems?.first(where: { $0.name == "project_ids" })?.value + } + let groupBys = requests.compactMap { request -> String? in + guard let url = request.url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + return components.queryItems?.first(where: { $0.name == "group_by" })?.value + } + + #expect(snapshot.projectID == "proj_abc") + #expect(snapshot.toUsageSnapshot().identity?.accountOrganization == "Project: proj_abc") + #expect(requests.count == 2) + #expect(projectIDs == ["proj_abc", "proj_abc"]) + #expect(groupBys == ["line_item", "model"]) + } + + @Test + func `admin usage retries transient completions failure once`() async throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let emptyPage = Data(#"{"object":"page","data":[],"has_more":false,"next_page":null}"#.utf8) + let completions = Data(""" + { + "object": "page", + "data": [ + { + "object": "bucket", + "start_time": 1700000000, + "end_time": 1700086400, + "results": [ + { + "object": "organization.usage.completions.result", + "input_tokens": 10, + "output_tokens": 5, + "num_model_requests": 1, + "model": "gpt-5.2" + } + ] + } + ], + "has_more": false, + "next_page": null + } + """.utf8) + let transport = OpenAIAdminUsageRetryScript(costs: emptyPage, completions: completions) + + let snapshot = try await OpenAIAPIUsageFetcher.fetchUsage( + apiKey: "sk-test", + costsURL: #require(URL(string: "https://api.openai.test/v1/organization/costs")), + completionsURL: #require(URL(string: "https://api.openai.test/v1/organization/usage/completions")), + session: transport, + now: now, + historyDays: 1, + retryPolicy: ProviderHTTPRetryPolicy(maxRetries: 1, baseDelaySeconds: 0, maxDelaySeconds: 0)) + + #expect(snapshot.latestDay.totalTokens == 15) + #expect(snapshot.latestDay.requests == 1) + #expect(await transport.completionsRequestCount() == 2) + } + + @Test + func `maps admin usage to openai usage snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 8.5, + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250, + lineItems: [], + models: []), + ], + updatedAt: now) + + let usage = apiUsage.toUsageSnapshot() + + #expect(usage.primary == nil) + #expect(usage.providerCost?.used == 8.5) + #expect(usage.providerCost?.limit == 0) + #expect(usage.providerCost?.period == "Last 30 days") + #expect(usage.openAIAPIUsage?.last30Days.requests == 42) + #expect(usage.identity?.loginMethod == "Admin API") + } + + @Test + func `maps project scoped admin usage to cost token snapshot`() { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-13", + startTime: now.addingTimeInterval(-86400), + endTime: now, + costUSD: 2.25, + requests: 3, + inputTokens: 300, + cachedInputTokens: 100, + outputTokens: 200, + totalTokens: 500, + lineItems: [], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2", + requests: 3, + inputTokens: 300, + cachedInputTokens: 100, + outputTokens: 200, + totalTokens: 500), + ]), + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: now, + endTime: now.addingTimeInterval(86400), + costUSD: 8.5, + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250, + lineItems: [], + models: [ + OpenAIAPIUsageSnapshot.ModelBreakdown( + name: "gpt-5.2-codex", + requests: 42, + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 250, + totalTokens: 1250), + ]), + ], + updatedAt: now, + historyDays: 7, + projectID: " proj_abc ") + + let usage = apiUsage.toUsageSnapshot() + let snapshot = apiUsage.toCostUsageTokenSnapshot() + + #expect(apiUsage.projectID == "proj_abc") + #expect(usage.identity?.loginMethod == "Admin API: proj_abc") + #expect(usage.identity?.accountOrganization == "Project: proj_abc") + #expect(snapshot.historyDays == 7) + #expect(snapshot.currencyCode == "USD") + #expect(snapshot.sessionCostUSD == 8.5) + #expect(snapshot.sessionTokens == 1250) + #expect(snapshot.sessionRequests == 42) + #expect(snapshot.last30DaysCostUSD == 10.75) + #expect(snapshot.last30DaysTokens == 1750) + #expect(snapshot.last30DaysRequests == 45) + #expect(snapshot.daily.count == 2) + #expect(snapshot.daily[1].cacheReadTokens == 400) + #expect(snapshot.daily[1].requestCount == 42) + #expect(snapshot.daily[1].modelBreakdowns?.first?.requestCount == 42) + #expect(snapshot.daily[1].modelBreakdowns?.first?.modelName == "gpt-5.2-codex") + } +} + +private actor OpenAIAdminUsageRetryScript: ProviderHTTPTransport { + private let costs: Data + private let completions: Data + private var completionsRequests = 0 + + init(costs: Data, completions: Data) { + self.costs = costs + self.completions = completions + } + + func completionsRequestCount() -> Int { + self.completionsRequests + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + let url = request.url ?? URL(string: "https://api.openai.test")! + if url.path.contains("/usage/completions") { + self.completionsRequests += 1 + if self.completionsRequests == 1 { + return (Data(), HTTPURLResponse( + url: url, + statusCode: 503, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } + return (self.completions, HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } + + return (self.costs, HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: nil)!) + } +} diff --git a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift index 0ee8e0e7..37db0483 100644 --- a/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift +++ b/Tests/CodexBarTests/OpenAIDashboardNavigationDelegateTests.swift @@ -59,6 +59,22 @@ struct OpenAIDashboardNavigationDelegateTests { } } + @MainActor + @Test + func `explicit cancel completes with cancellation error`() { + var result: Result? + let delegate = NavigationDelegate { result = $0 } + + delegate.cancel() + + switch result { + case let .failure(error)?: + #expect(error is CancellationError) + default: + #expect(Bool(false)) + } + } + @MainActor @Test func `commit completes navigation successfully after grace period`() async { diff --git a/Tests/CodexBarTests/OpenAIDashboardSparkTests.swift b/Tests/CodexBarTests/OpenAIDashboardSparkTests.swift new file mode 100644 index 00000000..45ad343f --- /dev/null +++ b/Tests/CodexBarTests/OpenAIDashboardSparkTests.swift @@ -0,0 +1,232 @@ +import Foundation +import Testing +@testable import CodexBarCore + +/// Dashboard-path coverage for Codex `additional_rate_limits` (e.g. GPT-5.3-Codex-Spark): the +/// OpenAI web dashboard usage API decodes the same `wham/usage` JSON as the OAuth path, so Spark +/// limits must survive the `dashboardAPIData -> DashboardSnapshotComponents -> OpenAIDashboardSnapshot +/// -> fromAttachedDashboard -> UsageSnapshot.extraRateWindows` chain without disturbing the +/// existing primary/weekly/credits/plan mapping. +struct OpenAIDashboardSparkTests { + private static func response(from json: String) throws -> CodexUsageResponse { + try JSONDecoder().decode(CodexUsageResponse.self, from: Data(json.utf8)) + } + + @Test + func `dashboard api data maps additional spark limit into extra windows`() throws { + let json = """ + { + "plan_type": "pro", + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 }, + "secondary_window": { "used_percent": 43, "reset_at": 1767407914, "limit_window_seconds": 604800 } + }, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "gpt_5_3_codex_spark", + "rate_limit": { + "primary_window": { "used_percent": 30, "reset_at": 1766948068, "limit_window_seconds": 18000 }, + "secondary_window": { "used_percent": 100, "reset_at": 1767407914, "limit_window_seconds": 604800 } + } + } + ] + } + """ + let response = try Self.response(from: json) + let apiData = OpenAIDashboardFetcher.dashboardAPIData(from: response) + // Primary/weekly/credits/plan continue to map exactly as before. + #expect(apiData.primaryLimit?.usedPercent == 22) + #expect(apiData.secondaryLimit?.usedPercent == 43) + #expect(apiData.accountPlan == "pro") + // Spark surfaces with stable ids/titles for the additional 5-hour and weekly windows. + #expect(apiData.extraRateWindows.count == 2) + let spark = try #require(apiData.extraRateWindows.first) + #expect(spark.id == "codex-spark") + #expect(spark.title == "Codex Spark 5-hour") + #expect(spark.window.usedPercent == 30) + #expect(spark.window.windowMinutes == 300) + #expect(spark.window.resetsAt != nil) + let weekly = try #require(apiData.extraRateWindows.last) + #expect(weekly.id == "codex-spark-weekly") + #expect(weekly.title == "Codex Spark Weekly") + #expect(weekly.window.usedPercent == 100) + #expect(weekly.window.windowMinutes == 10080) + #expect(weekly.window.resetsAt != nil) + } + + @Test + func `dashboard api data has empty extra windows when additional limits are absent`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 } + } + } + """ + let response = try Self.response(from: json) + let apiData = OpenAIDashboardFetcher.dashboardAPIData(from: response) + #expect(apiData.primaryLimit?.usedPercent == 22) + #expect(apiData.extraRateWindows.isEmpty) + } + + @Test + func `dashboard api data tolerates non array additional limits while keeping primary`() throws { + let json = """ + { + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 } + }, + "additional_rate_limits": "unexpected" + } + """ + let response = try Self.response(from: json) + let apiData = OpenAIDashboardFetcher.dashboardAPIData(from: response) + #expect(apiData.primaryLimit?.usedPercent == 22) + #expect(apiData.extraRateWindows.isEmpty) + } + + @Test + func `dashboard api data keeps valid spark when a malformed sibling is present`() throws { + // Lossy per-element decode (shared with the OAuth path via CodexUsageResponse) means a single + // malformed entry cannot discard its valid siblings. + let json = """ + { + "rate_limit": { + "primary_window": { "used_percent": 22, "reset_at": 1766948068, "limit_window_seconds": 18000 } + }, + "additional_rate_limits": [ + "garbage-not-an-object", + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "gpt_5_3_codex_spark", + "rate_limit": { + "primary_window": { "used_percent": 30, "reset_at": 1766948068, "limit_window_seconds": 18000 } + } + }, + 42 + ] + } + """ + let response = try Self.response(from: json) + let apiData = OpenAIDashboardFetcher.dashboardAPIData(from: response) + #expect(apiData.primaryLimit?.usedPercent == 22) + #expect(apiData.extraRateWindows.count == 1) + #expect(apiData.extraRateWindows.first?.id == "codex-spark") + #expect(apiData.extraRateWindows.first?.window.usedPercent == 30) + } + + @Test + func `dashboard snapshot exposes extra rate windows via to usage snapshot`() throws { + let now = Date(timeIntervalSince1970: 1_766_948_000) + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + primaryLimit: RateWindow( + usedPercent: 22, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondaryLimit: RateWindow( + usedPercent: 43, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil), + extraRateWindows: [ + NamedRateWindow( + id: "codex-spark", + title: "Codex Spark 5-hour", + window: RateWindow( + usedPercent: 30, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil)), + NamedRateWindow( + id: "codex-spark-weekly", + title: "Codex Spark Weekly", + window: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil)), + ], + updatedAt: now) + + let usage = try #require(snapshot.toUsageSnapshot(provider: .codex)) + // Primary/weekly behavior preserved. + #expect(usage.primary?.usedPercent == 22) + #expect(usage.secondary?.usedPercent == 43) + // Spark surfaces through UsageSnapshot.extraRateWindows for dashboard-source users. + let extras = try #require(usage.extraRateWindows) + #expect(extras.map(\.id) == ["codex-spark", "codex-spark-weekly"]) + #expect(extras.first?.window.usedPercent == 30) + #expect(extras.last?.window.usedPercent == 100) + } + + @Test + func `dashboard snapshot codable round trips extra rate windows`() throws { + let now = Date(timeIntervalSince1970: 1_766_948_000) + let snapshot = OpenAIDashboardSnapshot( + signedInEmail: nil, + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: [], + usageBreakdown: [], + creditsPurchaseURL: nil, + extraRateWindows: [ + NamedRateWindow( + id: "codex-spark", + title: "Codex Spark 5-hour", + window: RateWindow( + usedPercent: 30, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(60 * 60), + resetDescription: nil)), + NamedRateWindow( + id: "codex-spark-weekly", + title: "Codex Spark Weekly", + window: RateWindow( + usedPercent: 100, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60), + resetDescription: nil)), + ], + updatedAt: now) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let data = try encoder.encode(snapshot) + let decoded = try decoder.decode(OpenAIDashboardSnapshot.self, from: data) + #expect(decoded.extraRateWindows?.map(\.id) == ["codex-spark", "codex-spark-weekly"]) + #expect(decoded.extraRateWindows?.first?.window.usedPercent == 30) + #expect(decoded.extraRateWindows?.last?.window.usedPercent == 100) + } + + @Test + func `dashboard snapshot decoder preserves absence of extra rate windows`() throws { + // Older cached snapshots predate the field; decoding such payloads must yield nil and never + // throw, so existing dashboard caches keep working. + let json = """ + { + "signedInEmail": "codex@example.com", + "codeReviewRemainingPercent": null, + "creditEvents": [], + "dailyBreakdown": [], + "usageBreakdown": [], + "creditsPurchaseURL": null, + "updatedAt": "2026-04-30T19:27:07Z" + } + """ + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let snapshot = try decoder.decode(OpenAIDashboardSnapshot.self, from: Data(json.utf8)) + #expect(snapshot.extraRateWindows == nil) + } +} diff --git a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift index eead8bb8..e9a016db 100644 --- a/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift +++ b/Tests/CodexBarTests/OpenAIWebRefreshGateTests.swift @@ -8,7 +8,8 @@ struct OpenAIWebRefreshGateTests { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: true, - force: false)) + force: false, + refreshPhase: .regular)) #expect(shouldRun == false) } @@ -18,7 +19,8 @@ struct OpenAIWebRefreshGateTests { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: false, - force: false)) + force: false, + refreshPhase: .regular)) #expect(shouldRun == true) } @@ -28,7 +30,48 @@ struct OpenAIWebRefreshGateTests { let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( accessEnabled: true, batterySaverEnabled: true, - force: true)) + force: true, + refreshPhase: .regular)) + + #expect(shouldRun == true) + } + + @Test + func `Startup skips automatic OpenAI web refreshes`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: false, + force: false, + refreshPhase: .startup)) + + #expect(shouldRun == false) + } + + @Test + func `Startup connectivity retry remains startup only for OpenAI web refresh gate`() { + let providerPhase = UsageStore.refreshPhase( + hasCompletedInitialRefresh: true) + let openAIWebPhase = UsageStore.openAIWebRefreshPhase( + providerRefreshPhase: providerPhase, + startupConnectivityRetryAttempt: 1) + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: false, + force: false, + refreshPhase: openAIWebPhase)) + + #expect(providerPhase == .regular) + #expect(openAIWebPhase == .startup) + #expect(shouldRun == false) + } + + @Test + func `Manual startup refresh still forces OpenAI web refreshes`() { + let shouldRun = UsageStore.shouldRunOpenAIWebRefresh(.init( + accessEnabled: true, + batterySaverEnabled: true, + force: true, + refreshPhase: .startup)) #expect(shouldRun == true) } @@ -110,4 +153,36 @@ struct OpenAIWebRefreshGateTests { #expect(shouldSkip == false) } + + @Test + func `Empty dashboard history retry is throttled after a recent attempt`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebEmptyHistoryRetry(.init( + force: false, + accountDidChange: false, + lastError: nil, + lastSnapshotAt: now.addingTimeInterval(-120), + lastAttemptAt: now.addingTimeInterval(-60), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == true) + } + + @Test + func `Empty dashboard history retry runs once for a newer empty snapshot`() { + let now = Date() + + let shouldSkip = UsageStore.shouldSkipOpenAIWebEmptyHistoryRetry(.init( + force: false, + accountDidChange: false, + lastError: nil, + lastSnapshotAt: now.addingTimeInterval(-60), + lastAttemptAt: now.addingTimeInterval(-120), + now: now, + refreshInterval: 300)) + + #expect(shouldSkip == false) + } } diff --git a/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift new file mode 100644 index 00000000..ca060b0a --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoLocalUsageReaderTests.swift @@ -0,0 +1,299 @@ +#if os(macOS) + +import Foundation +import SQLite3 +import Testing +@testable import CodexBarCore + +struct OpenCodeGoLocalUsageReaderTests { + @Test + func `reads local OpenCode Go history into usage windows`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-05T12:00:00.000Z"), + cost: 6.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-02-25T07:53:16.000Z"), + cost: 2.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 30) + #expect(snapshot.monthlyUsagePercent == 18.3) + #expect(snapshot.rollingResetInSec == 14400) + #expect(snapshot.weeklyResetInSec == 216_000) + #expect(snapshot.monthlyResetInSec == 1_626_796) + } + + @Test + func `auth without history falls through to web strategy`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + + #expect(throws: OpenCodeGoLocalUsageError.historyUnavailable("database not found")) { + _ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + } + } + + @Test + func `auth with unreadable history falls through to web strategy`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + var db: OpaquePointer? + guard sqlite3_open(env.databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + sqlite3_close(db) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + + #expect(throws: OpenCodeGoLocalUsageError.self) { + _ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + } + } + + @Test + func `monthly window keeps original anchor after shorter month clamp`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-01-31T00:00:00.000Z"), + cost: 1.0) + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-29T10:00:00.000Z"), + cost: 6.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let now = Date(timeIntervalSince1970: TimeInterval(Self.ms("2026-03-29T12:00:00.000Z")) / 1000) + let snapshot = try reader.fetch(now: now) + + #expect(snapshot.monthlyUsagePercent == 10) + #expect(snapshot.monthlyResetInSec == 129_600) + } + + @Test + func `reads step finish parts when message only stores metadata`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + let messageID = try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: nil) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 10) + #expect(snapshot.monthlyUsagePercent == 5) + } + + @Test + func `does not double count step finish parts when message has cost`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + try Self.writeAuth(to: env.authURL) + try Self.createDatabase(at: env.databaseURL) + let messageID = try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, + messageID: messageID, + createdMs: Self.ms("2026-03-06T11:00:00.000Z"), + cost: 3.0) + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + let snapshot = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + + #expect(snapshot.rollingUsagePercent == 25) + #expect(snapshot.weeklyUsagePercent == 10) + #expect(snapshot.monthlyUsagePercent == 5) + } + + @Test + func `missing auth and history is not detected`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + let reader = OpenCodeGoLocalUsageReader(authURL: env.authURL, databaseURL: env.databaseURL) + + #expect(throws: OpenCodeGoLocalUsageError.notDetected) { + _ = try reader.fetch(now: Date(timeIntervalSince1970: 1_772_798_400)) + } + } + + private static func makeEnvironment() throws -> (root: URL, authURL: URL, databaseURL: URL) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodeGoLocalUsageReaderTests-\(UUID().uuidString)", isDirectory: true) + let directory = root + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return ( + root, + directory.appendingPathComponent("auth.json", isDirectory: false), + directory.appendingPathComponent("opencode.db", isDirectory: false)) + } + + private static func writeAuth(to url: URL) throws { + let data = Data(#"{"opencode-go":{"type":"api-key","key":"go-key"}}"#.utf8) + try data.write(to: url) + } + + private static func createDatabase(at url: URL) throws { + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try Self.exec( + db: db, + sql: """ + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + """) + } + + @discardableResult + private static func insertMessage(databaseURL: URL, createdMs: Int64, cost: Double?) throws -> String { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + let messageID = UUID().uuidString + var payload: [String: Any] = [ + "providerID": "opencode-go", + "role": "assistant", + "time": ["created": createdMs], + ] + if let cost { + payload["cost"] = cost + } + let data = try JSONSerialization.data(withJSONObject: payload) + let json = String(data: data, encoding: .utf8) ?? "{}" + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, messageID, -1, transient) + sqlite3_bind_text(stmt, 2, "session-1", -1, transient) + sqlite3_bind_text(stmt, 3, json, -1, transient) + sqlite3_bind_int64(stmt, 4, createdMs) + sqlite3_bind_int64(stmt, 5, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + return messageID + } + + private static func insertStepFinishPart( + databaseURL: URL, + messageID: String, + createdMs: Int64, + cost: Double) throws + { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + let payload: [String: Any] = [ + "type": "step-finish", + "cost": cost, + "tokens": ["input": 1, "output": 1, "total": 2], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let json = String(data: data, encoding: .utf8) ?? "{}" + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO part (id, message_id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, UUID().uuidString, -1, transient) + sqlite3_bind_text(stmt, 2, messageID, -1, transient) + sqlite3_bind_text(stmt, 3, "session-1", -1, transient) + sqlite3_bind_text(stmt, 4, json, -1, transient) + sqlite3_bind_int64(stmt, 5, createdMs) + sqlite3_bind_int64(stmt, 6, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func exec(db: OpaquePointer?, sql: String) throws { + var message: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &message) == SQLITE_OK else { + sqlite3_free(message) + throw SQLiteTestError.exec + } + } + + private static func ms(_ iso: String) -> Int64 { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return Int64((formatter.date(from: iso)?.timeIntervalSince1970 ?? 0) * 1000) + } + + private enum SQLiteTestError: Error { + case open + case prepare + case step + case exec + } +} + +#endif diff --git a/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift new file mode 100644 index 00000000..4c4d4d39 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoProviderStrategyTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodeGoProviderStrategyTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private func makeContext(sourceMode: ProviderSourceMode = .auto) -> ProviderFetchContext { + let env: [String: String] = [:] + return ProviderFetchContext( + runtime: .app, + sourceMode: sourceMode, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: nil, + fetcher: UsageFetcher(environment: env), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + @Test + func `auto source prefers web before local fallback`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(self.makeContext()) + + #expect(strategies.map(\.id) == ["opencodego.web", "opencodego.local"]) + } + + @Test + func `web source does not include local fallback`() async { + let descriptor = OpenCodeGoProviderDescriptor.makeDescriptor() + let strategies = await descriptor.fetchPlan.pipeline.resolveStrategies(self.makeContext(sourceMode: .web)) + + #expect(strategies.map(\.id) == ["opencodego.web"]) + } + + @Test + func `web strategy falls back to local only for auth setup failures in auto mode`() { + let strategy = OpenCodeGoUsageFetchStrategy() + let autoContext = self.makeContext() + let webContext = self.makeContext(sourceMode: .web) + + #expect(strategy.shouldFallback(on: OpenCodeGoSettingsError.missingCookie, context: autoContext)) + #expect(strategy.shouldFallback(on: OpenCodeGoSettingsError.invalidCookie, context: autoContext)) + #expect(strategy.shouldFallback(on: OpenCodeGoUsageError.invalidCredentials, context: autoContext)) + #expect(!strategy.shouldFallback(on: OpenCodeGoUsageError.networkError("timeout"), context: autoContext)) + #expect(!strategy.shouldFallback(on: OpenCodeGoSettingsError.missingCookie, context: webContext)) + } +} diff --git a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift index 7929ae3b..131bfddd 100644 --- a/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoUsageFetcherErrorTests.swift @@ -271,6 +271,34 @@ struct OpenCodeGoUsageFetcherErrorTests { #expect(snapshot.toUsageSnapshot().providerCost?.period == "Zen balance") } + @Test + func `optional zen balance helper uses normalized cookie and workspace override`() async throws { + defer { + OpenCodeGoStubURLProtocol.handler = nil + } + + var observedCookie: String? + OpenCodeGoStubURLProtocol.handler = { request in + guard let url = request.url else { throw URLError(.badURL) } + observedCookie = request.value(forHTTPHeaderField: "Cookie") + #expect(url.path == "/workspace/wrk_TEST123") + return Self.makeResponse( + url: url, + body: #"

現在の残高 $98.76

"#, + statusCode: 200, + contentType: "text/html") + } + + let balance = try await OpenCodeGoUsageFetcher.fetchOptionalZenBalance( + cookieHeader: "provider=google; auth=test", + timeout: 2, + workspaceIDOverride: "https://opencode.ai/workspace/wrk_TEST123/go", + session: self.makeSession()) + + #expect(balance == 98.76) + #expect(observedCookie == "auth=test") + } + @Test func `optional zen balance failure does not fail subscription usage`() async throws { defer { diff --git a/Tests/CodexBarTests/PathBuilderTests.swift b/Tests/CodexBarTests/PathBuilderTests.swift index 533f8d9e..7ac329e0 100644 --- a/Tests/CodexBarTests/PathBuilderTests.swift +++ b/Tests/CodexBarTests/PathBuilderTests.swift @@ -267,6 +267,77 @@ struct PathBuilderTests { #expect(!allowed) } + + @Test + func `Codex launch preflight allows valid signed command line binary assessment`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/opt/homebrew/bin/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in "\(path): rejected (the code is valid but does not seem to be an app)" }, + isMachOExecutable: { _ in true }) + + #expect(allowed) + } + + @Test + func `Codex launch preflight blocks revoked assessment even with non app rejection text`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/opt/homebrew/bin/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { _ in + """ + rejected (the code is valid but does not seem to be an app) + CSSMERR_TP_CERT_REVOKED + """ + }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight ignores benign text in path when verdict is generic rejection`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/tmp/code is valid but does not seem to be an app/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in "\(path): rejected\nsource=no usable signature" }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight ignores benign text before verdict separator`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/tmp/x: code is valid but does not seem to be an app/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in "\(path): rejected\nsource=no usable signature" }, + isMachOExecutable: { _ in true }) + + #expect(!allowed) + } + + @Test + func `Codex launch preflight ignores blocked words in accepted path and source fields`() { + let allowed = CodexLaunchPreflight.isLaunchCandidateAllowed( + path: "/tmp/rejected/quarantine/codex", + fileManager: MockFileManager(executables: []), + hasExtendedAttribute: { _, name in name == "com.apple.quarantine" }, + spctlAssessment: { path in + """ + \(path): accepted + source=revoked quarantine marker + origin=malware test fixture + """ + }, + isMachOExecutable: { _ in true }) + + #expect(allowed) + } #endif @Test diff --git a/Tests/CodexBarTests/PopupLocalizationTests.swift b/Tests/CodexBarTests/PopupLocalizationTests.swift new file mode 100644 index 00000000..8389645f --- /dev/null +++ b/Tests/CodexBarTests/PopupLocalizationTests.swift @@ -0,0 +1,183 @@ +import CodexBarCore +import Foundation +import SwiftUI +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct PopupLocalizationTests { + @Test + func `descriptor account labels use selected localization`() throws { + try CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { + let suite = "PopupLocalizationTests-descriptor" + let settings = try Self.makeSettingsStore(suite: suite) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "free")), + provider: .codex) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let lines = Self.textLines(from: descriptor) + + #expect(lines.contains("帳號: codex@example.com")) + #expect(lines.contains("方案: Free")) + #expect(!lines.contains("Account: codex@example.com")) + #expect(!lines.contains("Plan: Free")) + } + } + + @Test + func `inline dashboard labels use selected localization`() throws { + try CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.openrouter]) + let usage = OpenRouterUsageSnapshot( + totalCredits: 100, + totalUsage: 40, + balance: 60, + usedPercent: 40, + keyDataFetched: true, + keyLimit: 25, + keyUsage: 10, + keyUsageDaily: 1.25, + keyUsageWeekly: 7.5, + keyUsageMonthly: 18.75, + rateLimit: OpenRouterRateLimit(requests: 100, interval: "10s"), + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .openrouter, + metadata: metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let dashboard = try #require(model.inlineUsageDashboard) + + #expect(dashboard.kpis.map(\.title) == ["餘額", "今天", "週", "月"]) + #expect(dashboard.points.map(\.label) == ["今天", "週", "月"]) + #expect(dashboard.detailLines.contains("速率限制: 100 / 10s")) + } + } + + @Test + func `cookie source dynamic subtitles use selected localization`() { + CodexBarLocalizationOverride.$appLanguage.withValue("zh-Hant") { + let subtitle = ProviderCookieSourceUI.subtitle( + source: .manual, + keychainDisabled: false, + auto: "Automatically imports browser cookies.", + manual: "Paste a Cookie header or cURL capture from T3 Chat settings.", + off: "T3 Chat cookies are disabled.") + let disabledSubtitle = ProviderCookieSourceUI.subtitle( + source: .manual, + keychainDisabled: true, + auto: "Automatically imports browser cookies.", + manual: "Paste a Cookie header or cURL capture from T3 Chat settings.", + off: "T3 Chat cookies are disabled.") + let jsonBundleSubtitle = ProviderCookieSourceUI.subtitle( + source: .manual, + keychainDisabled: false, + auto: "Automatically imports browser cookies.", + manual: "Paste the localStorage JSON bundle from Windsurf session.", + off: "Windsurf cookies are disabled.") + + #expect(subtitle.contains("貼上")) + #expect(!subtitle.contains("Paste a Cookie")) + #expect(disabledSubtitle.contains("鑰匙圈")) + #expect(!disabledSubtitle.contains("Keychain access")) + #expect(jsonBundleSubtitle.contains("來自 Windsurf session 的 localStorage JSON")) + } + } + + @Test + func `provider organization entries preserve provider supplied text`() throws { + let settings = try Self.makeSettingsStore(suite: "PopupLocalizationTests-organizations") + settings.kiloKnownOrganizations = [ + KiloOrganization(id: "org_cost", name: "Cost", role: "Today"), + ] + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let context = ProviderSettingsContext( + provider: .kilo, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { _ in nil }, + setStatusText: { _, _ in }, + lastAppActiveRunAt: { _ in nil }, + setLastAppActiveRunAt: { _, _ in }, + requestConfirmation: { _ in }) + let descriptor = try #require(KiloProviderImplementation().settingsOrganizations(context: context)) + let orgEntry = try #require(descriptor.entries().first { $0.id == "org_cost" }) + + #expect(orgEntry.title == "Cost") + #expect(orgEntry.localizesTitle == false) + #expect(orgEntry.subtitle == "Today") + #expect(orgEntry.localizesSubtitle == false) + } + + private static func makeSettingsStore(suite: String) throws -> SettingsStore { + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + return settings + } + + private static func textLines(from descriptor: MenuDescriptor) -> [String] { + descriptor.sections.flatMap(\.entries).compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + } +} diff --git a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift index 7032c976..c2bf3a36 100644 --- a/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift +++ b/Tests/CodexBarTests/ProviderConfigEnvironmentTests.swift @@ -121,6 +121,21 @@ struct ProviderConfigEnvironmentTests { #expect(ProviderTokenResolver.openAIAPIToken(environment: env) == "config-openai-token") } + @Test + func `openai config override applies project ID without replacing environment key`() { + let config = ProviderConfig(id: .openai, workspaceID: "proj_config") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey: "env-admin-token", + ], + provider: .openai, + config: config) + + #expect(env[OpenAIAPISettingsReader.adminAPIKeyEnvironmentKey] == "env-admin-token") + #expect(env[OpenAIAPISettingsReader.projectIDEnvironmentKey] == "proj_config") + #expect(OpenAIAPISettingsReader.projectID(environment: env) == "proj_config") + } + @Test func `applies Azure OpenAI config overrides`() { let config = ProviderConfig( @@ -181,6 +196,130 @@ struct ProviderConfigEnvironmentTests { #expect(BedrockSettingsReader.hasCredentials(environment: env)) } + @Test + func `bedrock merged static credentials win over inherited AWS_PROFILE`() { + let config = ProviderConfig( + id: .bedrock, + secretKey: "config-secret", + region: "eu-central-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + BedrockSettingsReader.profileKey: "work", + BedrockSettingsReader.accessKeyIDKey: "env-access", + ], + provider: .bedrock, + config: config) + + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "env-access") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "config-secret") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "eu-central-1") + #expect(BedrockSettingsReader.authMode(environment: env) == .keys) + } + + @Test + func `bedrock profile mode projects AWS_PROFILE without saved static keys`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIATEST", + secretKey: "secret", + region: "eu-west-1", + awsProfile: "work", + awsAuthMode: "profile") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.authModeKey] == "profile") + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(env[BedrockSettingsReader.regionKeys[0]] == "eu-west-1") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == nil) + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == nil) + } + + @Test + func `bedrock config without explicit mode preserves env profile inference`() { + let config = ProviderConfig(id: .bedrock, region: "us-east-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [BedrockSettingsReader.profileKey: "work"], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.authModeKey] == nil) + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(BedrockSettingsReader.authMode(environment: env) == .profile) + } + + @Test + func `bedrock saved static keys survive base AWS_PROFILE when auth mode is unset`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIASAVED", + secretKey: "saved-secret", + region: "us-east-1") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [BedrockSettingsReader.profileKey: "work"], + provider: .bedrock, + config: config) + // Upgrade path: saved keys win over an inherited AWS_PROFILE, no silent switch. + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIASAVED") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "saved-secret") + #expect(BedrockSettingsReader.authMode(environment: env) == .keys) + } + + @Test + func `bedrock profile mode preserves inherited static credentials for environment source profiles`() { + let config = ProviderConfig(id: .bedrock, awsProfile: "work", awsAuthMode: "profile") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + BedrockSettingsReader.accessKeyIDKey: "AKIAINHERITED", + BedrockSettingsReader.secretAccessKeyKey: "inherited-secret", + BedrockSettingsReader.sessionTokenKey: "inherited-token", + ], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIAINHERITED") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "inherited-secret") + #expect(env[BedrockSettingsReader.sessionTokenKey] == "inherited-token") + #expect(env[BedrockSettingsReader.profileKey] == "work") + } + + @Test + func `bedrock env profile mode does not project saved static credentials`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIASAVED", + secretKey: "saved-secret") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [ + BedrockSettingsReader.authModeKey: "profile", + BedrockSettingsReader.profileKey: "work", + ], + provider: .bedrock, + config: config) + + #expect(env[BedrockSettingsReader.authModeKey] == "profile") + #expect(env[BedrockSettingsReader.profileKey] == "work") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == nil) + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == nil) + } + + @Test + func `bedrock keys mode still projects static credentials`() { + let config = ProviderConfig( + id: .bedrock, + apiKey: "AKIATEST", + secretKey: "secret", + region: "us-west-2", + awsAuthMode: "keys") + let env = ProviderConfigEnvironment.applyAPIKeyOverride( + base: [:], + provider: .bedrock, + config: config) + #expect(env[BedrockSettingsReader.authModeKey] == "keys") + #expect(env[BedrockSettingsReader.accessKeyIDKey] == "AKIATEST") + #expect(env[BedrockSettingsReader.secretAccessKeyKey] == "secret") + #expect(env[BedrockSettingsReader.profileKey] == nil) + } + @Test func `ignores legacy API key override for deepseek`() { let config = ProviderConfig(id: .deepseek, apiKey: "ds-token") diff --git a/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift new file mode 100644 index 00000000..71065bdf --- /dev/null +++ b/Tests/CodexBarTests/ProviderDiagnosticExportTests.swift @@ -0,0 +1,313 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct ProviderDiagnosticExportTests { + @Test + func `generic diagnostic export encodes safe provider envelope`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let export = ProviderDiagnosticExport( + timestamp: now, + provider: "openai", + displayName: "OpenAI", + source: "api", + sourceMode: "auto", + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["api"]), + usage: ProviderDiagnosticUsageSummary(from: UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(18000), + resetDescription: "raw local text"), + secondary: nil, + updatedAt: now)), + fetchAttempts: [ + ProviderDiagnosticFetchAttempt( + kind: "api", + wasAvailable: true, + errorCategory: nil), + ], + error: nil, + settings: ProviderDiagnosticSettingsSummary(sourceMode: .auto), + details: nil) + + let json = try self.json(export) + + #expect(json.contains("\"provider\"")) + #expect(json.contains("\"openai\"")) + #expect(json.contains("\"auth\"")) + #expect(json.contains("\"hasResetDescription\"")) + #expect(!json.contains("sk-cp-")) + #expect(!json.contains("sk-api-")) + #expect(!json.contains("Bearer")) + #expect(!json.contains("raw local text")) + #expect(!json.contains("errorMessage")) + #expect(!json.contains("localizedDescription")) + } + + @Test + func `raw error text never appears in encoded JSON`() throws { + let export = ProviderDiagnosticExport( + timestamp: Date(timeIntervalSince1970: 1_700_000_000), + provider: "minimax", + displayName: "MiniMax", + source: "failed", + sourceMode: "auto", + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["api"]), + usage: nil, + fetchAttempts: [ + ProviderDiagnosticFetchAttempt( + kind: "api", + wasAvailable: true, + errorCategory: "network"), + ], + error: ProviderDiagnosticError( + category: "network", + safeDescription: "Network error - check your connection"), + settings: ProviderDiagnosticSettingsSummary(sourceMode: .auto, apiRegion: "global"), + details: nil) + + let json = try self.json(export) + + #expect(!json.contains("connection refused")) + #expect(!json.contains("network probe")) + #expect(!json.contains("not safe to expose")) + #expect(!json.contains("localizedDescription")) + #expect(!json.contains("raw")) + #expect(!json.contains("errorMessage")) + #expect(json.contains("errorCategory")) + #expect(json.contains("\"network\"")) + } + + @Test + func `diagnostic error maps MiniMaxUsageError categories safely`() { + let networkError = MiniMaxUsageError.networkError("connection refused") + let invalidCreds = MiniMaxUsageError.invalidCredentials + let apiError = MiniMaxUsageError.apiError("HTTP 404") + let parseError = MiniMaxUsageError.parseFailed("unexpected") + + let diagNetwork = ProviderDiagnosticError(from: networkError, authConfigured: true) + #expect(diagNetwork.category == "network") + #expect(!diagNetwork.safeDescription.contains("connection refused")) + + let diagCreds = ProviderDiagnosticError(from: invalidCreds, authConfigured: true) + #expect(diagCreds.category == "auth") + + let diagAPI = ProviderDiagnosticError(from: apiError, authConfigured: true) + #expect(diagAPI.category == "api") + + let diagParse = ProviderDiagnosticError(from: parseError, authConfigured: true) + #expect(diagParse.category == "parse") + } + + @Test + func `no available strategy maps missing auth to auth category`() { + let error = ProviderFetchError.noAvailableStrategy(.minimax) + let diag = ProviderDiagnosticError(from: error, authConfigured: false) + + #expect(diag.category == "auth") + #expect(diag.safeDescription.contains("Authentication")) + } + + @Test + func `available failed strategy does not imply auth is configured`() { + let outcome = ProviderFetchOutcome( + result: .failure(ProviderFetchError.noAvailableStrategy(.antigravity)), + attempts: [ + ProviderFetchAttempt( + strategyID: "antigravity.local", + kind: .localProbe, + wasAvailable: true, + errorDescription: "unauthenticated local probe"), + ]) + + let summary = ProviderDiagnosticAuthSummary(configured: false, modes: []).resolved(with: outcome) + + #expect(!summary.configured) + #expect(summary.modes.isEmpty) + } + + @Test + func `fetch attempt error maps to safe category, never raw text`() { + let attemptWithRawError = ProviderFetchAttempt( + strategyID: "minimax.api", + kind: .apiToken, + wasAvailable: true, + errorDescription: "MiniMax API timeout after 30 seconds - connection refused for host platform.minimax.io") + let diagAttempt = ProviderDiagnosticFetchAttempt(from: attemptWithRawError) + #expect(diagAttempt.kind == "api") + #expect(diagAttempt.wasAvailable == true) + let errorCategoryOne = diagAttempt.errorCategory + #expect(errorCategoryOne == "network") + let cat1 = errorCategoryOne ?? "" + #expect(!cat1.contains("timeout")) + #expect(!cat1.contains("connection refused")) + #expect(!cat1.contains("platform.minimax.io")) + + let attemptWithAuthError = ProviderFetchAttempt( + strategyID: "minimax.web", + kind: .web, + wasAvailable: false, + errorDescription: "invalid auth token cookie HERTZ-SESSION=abc123") + let diagAuthAttempt = ProviderDiagnosticFetchAttempt(from: attemptWithAuthError) + #expect(diagAuthAttempt.wasAvailable == false) + let errorCategoryTwo = diagAuthAttempt.errorCategory + #expect(errorCategoryTwo == "auth") + let cat2 = errorCategoryTwo ?? "" + #expect(!cat2.contains("HERTZ-SESSION")) + } + + @Test + func `missing api key setup errors map to auth before api`() { + let category = ProviderDiagnosticFetchAttempt.errorCategoryLabel( + "Azure OpenAI API key not configured. Set AZURE_OPENAI_API_KEY.") + + #expect(category == "auth") + } + + @Test + func `MiniMax details map from MiniMaxUsageSnapshot correctly`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 250, + remainingPrompts: 750, + windowMinutes: 300, + usedPercent: 25, + resetsAt: now.addingTimeInterval(18000), + updatedAt: now, + services: nil) + + let details = MiniMaxDiagnosticDetails(from: snapshot) + #expect(details.planName == "Max") + #expect(details.availablePrompts == 1000) + #expect(details.currentPrompts == 250) + #expect(details.remainingPrompts == 750) + #expect(details.windowMinutes == 300) + #expect(details.usedPercent == 25) + } + + @Test + func `service usage maps from MiniMaxServiceUsage correctly`() throws { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let service = MiniMaxServiceUsage( + serviceType: "Text Generation", + windowType: "5 hours", + timeRange: "10:00-15:00(UTC+8)", + usage: 750, + limit: 1000, + percent: 75, + resetsAt: now.addingTimeInterval(18000), + resetDescription: "5 hours") + + let diagService = MiniMaxDiagnosticServiceUsage(from: service) + #expect(diagService.displayName == "Text Generation") + #expect(diagService.percent == 75) + #expect(diagService.windowType == "5 hours") + #expect(diagService.hasResetDescription == true) + + let json = try self.json(diagService) + #expect(json.contains("hasResetDescription")) + #expect(!json.contains("resetDescription")) + } + + @Test + func `builder creates generic safe diagnostic with error on failure`() { + let outcome = ProviderFetchOutcome( + result: .failure(MiniMaxUsageError.networkError("timeout")), + attempts: [ + ProviderFetchAttempt( + strategyID: "minimax.api", + kind: .apiToken, + wasAvailable: true, + errorDescription: "timeout"), + ]) + + let diag = ProviderDiagnosticExportBuilder.build(.init( + provider: .minimax, + descriptor: ProviderDescriptorRegistry.descriptor(for: .minimax), + outcome: outcome, + sourceMode: .auto, + settings: nil, + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["apiToken"]))) + + #expect(diag.provider == "minimax") + #expect(diag.source == "failed") + #expect(diag.auth.configured == true) + #expect(diag.usage == nil) + #expect(diag.error != nil) + #expect(diag.error?.category == "network") + #expect(diag.fetchAttempts.count == 1) + #expect(diag.fetchAttempts[0].errorCategory == "network") + } + + @Test + func `builder creates generic safe diagnostic with MiniMax details on success`() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + let snapshot = MiniMaxUsageSnapshot( + planName: "Max", + availablePrompts: 1000, + currentPrompts: 250, + remainingPrompts: 750, + windowMinutes: 300, + usedPercent: 25, + resetsAt: now.addingTimeInterval(18000), + updatedAt: now) + + let result = ProviderFetchResult( + usage: UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(18000), + resetDescription: nil), + secondary: nil, + tertiary: nil, + minimaxUsage: snapshot, + updatedAt: now), + credits: nil, + dashboard: nil, + sourceLabel: "api", + strategyID: "minimax.api", + strategyKind: .apiToken) + + let outcome = ProviderFetchOutcome( + result: .success(result), + attempts: [ + ProviderFetchAttempt( + strategyID: "minimax.api", + kind: .apiToken, + wasAvailable: true, + errorDescription: nil), + ]) + + let diag = ProviderDiagnosticExportBuilder.build(.init( + provider: .minimax, + descriptor: ProviderDescriptorRegistry.descriptor(for: .minimax), + outcome: outcome, + sourceMode: .auto, + settings: nil, + auth: ProviderDiagnosticAuthSummary(configured: true, modes: ["apiToken"]))) + + #expect(diag.provider == "minimax") + #expect(diag.source == "api") + #expect(diag.auth.configured == true) + #expect(diag.usage != nil) + #expect(diag.error == nil) + + guard case let .minimax(details) = diag.details else { + Issue.record("Expected MiniMax diagnostic details") + return + } + #expect(details.planName == "Max") + } + + private func json(_ value: some Encodable) throws -> String { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(value) + return String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/Tests/CodexBarTests/ProviderHTTPClientTests.swift b/Tests/CodexBarTests/ProviderHTTPClientTests.swift index 40a794d7..c07b43e0 100644 --- a/Tests/CodexBarTests/ProviderHTTPClientTests.swift +++ b/Tests/CodexBarTests/ProviderHTTPClientTests.swift @@ -81,6 +81,99 @@ struct ProviderHTTPClientTests { _ = try await transport.response(for: request) } } + + @Test + func `response helper retries transient HTTP status once`() async throws { + let script = ScriptedHTTPTransport(statusCodes: [503, 200]) + let request = try URLRequest(url: #require(URL(string: "https://example.com/retry"))) + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 200) + #expect(await script.requestCount() == 2) + } + + @Test + func `response helper retries transient URL error once`() async throws { + let script = ScriptedHTTPTransport(results: [ + .failure(URLError(.timedOut)), + .success(200), + ]) + let request = try URLRequest(url: #require(URL(string: "https://example.com/retry-error"))) + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 200) + #expect(await script.requestCount() == 2) + } + + @Test + func `response helper does not retry non idempotent methods`() async throws { + let script = ScriptedHTTPTransport(statusCodes: [503, 200]) + var request = try URLRequest(url: #require(URL(string: "https://example.com/post"))) + request.httpMethod = "POST" + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 503) + #expect(await script.requestCount() == 1) + } + + @Test + func `response helper does not retry auth failures`() async throws { + let script = ScriptedHTTPTransport(statusCodes: [403, 200]) + let request = try URLRequest(url: #require(URL(string: "https://example.com/forbidden"))) + + let response = try await script.response(for: request, retryPolicy: .testOneRetry) + + #expect(response.statusCode == 403) + #expect(await script.requestCount() == 1) + } +} + +extension ProviderHTTPRetryPolicy { + fileprivate static let testOneRetry = ProviderHTTPRetryPolicy( + maxRetries: 1, + baseDelaySeconds: 0, + maxDelaySeconds: 0) +} + +private actor ScriptedHTTPTransport: ProviderHTTPTransport { + enum Result { + case success(Int) + case failure(URLError) + } + + private var results: [Result] + private var requests: [URLRequest] = [] + + init(statusCodes: [Int]) { + self.results = statusCodes.map(Result.success) + } + + init(results: [Result]) { + self.results = results + } + + func requestCount() -> Int { + self.requests.count + } + + func data(for request: URLRequest) throws -> (Data, URLResponse) { + self.requests.append(request) + let next = self.results.isEmpty ? .success(200) : self.results.removeFirst() + switch next { + case let .success(statusCode): + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.com")!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil)! + return (Data(#"{"ok":true}"#.utf8), response) + case let .failure(error): + throw error + } + } } final class StubURLProtocol: URLProtocol { diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index de7989e3..53bcbf11 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -43,6 +43,16 @@ struct ProviderIconResourcesTests { } } + @Test + func `groq and grok provider icons are distinct`() throws { + let root = try Self.repoRoot() + let resources = root.appending(path: "Sources/CodexBar/Resources", directoryHint: .isDirectory) + let groq = try String(contentsOf: resources.appending(path: "ProviderIcon-groq.svg"), encoding: .utf8) + let grok = try String(contentsOf: resources.appending(path: "ProviderIcon-grok.svg"), encoding: .utf8) + + #expect(groq != grok) + } + private static func repoRoot() throws -> URL { var dir = URL(filePath: #filePath).deletingLastPathComponent() for _ in 0..<12 { diff --git a/Tests/CodexBarTests/ProviderLabelMetadataCharacterizationTests.swift b/Tests/CodexBarTests/ProviderLabelMetadataCharacterizationTests.swift new file mode 100644 index 00000000..9d3c01e9 --- /dev/null +++ b/Tests/CodexBarTests/ProviderLabelMetadataCharacterizationTests.swift @@ -0,0 +1,63 @@ +import CodexBarCore +import Testing + +struct ProviderLabelMetadataCharacterizationTests { + // MARK: - Label non-empty constraints + + @Test + func `displayName is non-empty for all providers`() { + for descriptor in ProviderDescriptorRegistry.all { + #expect( + !descriptor.metadata.displayName.isEmpty, + "Provider \(descriptor.id.rawValue) has empty displayName.") + } + } + + @Test + func `sessionLabel is non-empty for all providers`() { + for descriptor in ProviderDescriptorRegistry.all { + #expect( + !descriptor.metadata.sessionLabel.isEmpty, + "Provider \(descriptor.id.rawValue) has empty sessionLabel.") + } + } + + // MARK: - Known empty weeklyLabel exceptions + + @Test + func `weeklyLabel empty providers are explicitly characterized`() { + // Allowlist of providers known to have empty weeklyLabel on current main. + // If a new provider is added with empty weeklyLabel, this test fails and + // requires a deliberate decision to add it here — preventing silent regressions. + let knownEmptyWeeklyLabelProviders: Set = [.mistral] + for descriptor in ProviderDescriptorRegistry.all where descriptor.metadata.weeklyLabel.isEmpty { + #expect( + knownEmptyWeeklyLabelProviders.contains(descriptor.id), + "Provider \(descriptor.id.rawValue) has empty weeklyLabel and is not in the known exception list.") + } + } + + // MARK: - Invariant: supportsOpus implies opusLabel + + @Test + func `supportsOpus providers declare non-empty opusLabel`() { + for descriptor in ProviderDescriptorRegistry.all where descriptor.metadata.supportsOpus { + #expect( + descriptor.metadata.opusLabel != nil && !descriptor.metadata.opusLabel!.isEmpty, + "Provider \(descriptor.id.rawValue) has supportsOpus=true but opusLabel is nil or empty.") + } + } + + // MARK: - opusLabel structural constraint + + @Test + func `opusLabel is nil or non-empty`() { + for descriptor in ProviderDescriptorRegistry.all { + if let opusLabel = descriptor.metadata.opusLabel { + #expect( + !opusLabel.isEmpty, + "Provider \(descriptor.id.rawValue) has empty opusLabel string instead of nil.") + } + } + } +} diff --git a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift index 122f8e11..43ae2fe0 100644 --- a/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift +++ b/Tests/CodexBarTests/ProviderSettingsDescriptorTests.swift @@ -8,60 +8,13 @@ import Testing struct ProviderSettingsDescriptorTests { @Test func `toggle I ds are unique across providers`() throws { - let suite = "ProviderSettingsDescriptorTests-unique" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - var statusByID: [String: String] = [:] - var lastRunAtByID: [String: Date] = [:] + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-unique") var seenToggleIDs: Set = [] var seenActionIDs: Set = [] var seenPickerIDs: Set = [] for provider in UsageProvider.allCases { - let context = ProviderSettingsContext( - provider: provider, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { id in statusByID[id] }, - setStatusText: { id, text in - if let text { - statusByID[id] = text - } else { - statusByID.removeValue(forKey: id) - } - }, - lastAppActiveRunAt: { id in lastRunAtByID[id] }, - setLastAppActiveRunAt: { id, date in - if let date { - lastRunAtByID[id] = date - } else { - lastRunAtByID.removeValue(forKey: id) - } - }, - requestConfirmation: { _ in }, - runLoginFlow: {}) - + let context = fixture.settingsContext(provider: provider) let impl = try #require(ProviderCatalog.implementation(for: provider)) let toggles = impl.settingsToggles(context: context) for toggle in toggles { @@ -83,41 +36,24 @@ struct ProviderSettingsDescriptorTests { } @Test - func `codex exposes usage and cookie pickers`() throws { - let suite = "ProviderSettingsDescriptorTests-codex" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + func `openai exposes project id setting`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-openai-project") + let context = fixture.settingsContext(provider: .openai) + + let fields = OpenAIAPIProviderImplementation().settingsFields(context: context) + let project = try #require(fields.first(where: { $0.id == "openai-project-id" })) + project.binding.wrappedValue = "proj_abc" + + #expect(project.title == "Project ID") + #expect(project.subtitle.contains(OpenAIAPISettingsReader.projectIDEnvironmentKey)) + #expect(fixture.settings.openAIAPIProjectID == "proj_abc") + #expect(fixture.settings.providerConfig(for: .openai)?.sanitizedWorkspaceID == "proj_abc") + } - let context = ProviderSettingsContext( - provider: .codex, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }, - runLoginFlow: {}) + @Test + func `codex exposes usage and cookie pickers`() throws { + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-codex") + let context = fixture.settingsContext(provider: .codex) let pickers = CodexProviderImplementation().settingsPickers(context: context) let toggles = CodexProviderImplementation().settingsToggles(context: context) @@ -128,39 +64,8 @@ struct ProviderSettingsDescriptorTests { @Test func `codex exposes open AI web extras toggle as default off opt in`() throws { - let suite = "ProviderSettingsDescriptorTests-codex-openai-toggle" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - let context = ProviderSettingsContext( - provider: .codex, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-codex-openai-toggle") + let context = fixture.settingsContext(provider: .codex) let toggles = CodexProviderImplementation().settingsToggles(context: context) let extrasToggle = try #require(toggles.first(where: { $0.id == "codex-openai-web-extras" })) @@ -172,47 +77,16 @@ struct ProviderSettingsDescriptorTests { #expect(batterySaverToggle.binding.wrappedValue == false) #expect(batterySaverToggle.isVisible?() == false) - settings.openAIWebAccessEnabled = true + fixture.settings.openAIWebAccessEnabled = true #expect(batterySaverToggle.isVisible?() == true) } @Test func `claude exposes usage and cookie pickers`() throws { - let suite = "ProviderSettingsDescriptorTests-claude" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - settings.debugDisableKeychainAccess = false - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude") + fixture.settings.debugDisableKeychainAccess = false + let context = fixture.settingsContext(provider: .claude) - let context = ProviderSettingsContext( - provider: .claude, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }, - runLoginFlow: {}) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) #expect(pickers.contains(where: { $0.id == "claude-usage-source" })) #expect(pickers.contains(where: { $0.id == "claude-cookie-source" })) @@ -228,43 +102,11 @@ struct ProviderSettingsDescriptorTests { @Test func `claude prompt policy picker hidden when experimental reader selected`() throws { - let suite = "ProviderSettingsDescriptorTests-claude-prompt-hidden-experimental" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - settings.debugDisableKeychainAccess = false - settings.claudeOAuthKeychainReadStrategy = .securityCLIExperimental - - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - let context = ProviderSettingsContext( - provider: .claude, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }, - runLoginFlow: {}) + let fixture = try self.makeSettingsFixture( + suite: "ProviderSettingsDescriptorTests-claude-prompt-hidden-experimental") + fixture.settings.debugDisableKeychainAccess = false + fixture.settings.claudeOAuthKeychainReadStrategy = .securityCLIExperimental + let context = fixture.settingsContext(provider: .claude) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) @@ -273,41 +115,9 @@ struct ProviderSettingsDescriptorTests { @Test func `claude keychain prompt policy picker disabled when global keychain disabled`() throws { - let suite = "ProviderSettingsDescriptorTests-claude-keychain-disabled" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - settings.debugDisableKeychainAccess = true - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - let context = ProviderSettingsContext( - provider: .claude, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }, - runLoginFlow: {}) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-keychain-disabled") + fixture.settings.debugDisableKeychainAccess = true + let context = fixture.settingsContext(provider: .claude) let pickers = ClaudeProviderImplementation().settingsPickers(context: context) let keychainPicker = try #require(pickers.first(where: { $0.id == "claude-keychain-prompt-policy" })) @@ -318,15 +128,8 @@ struct ProviderSettingsDescriptorTests { @Test func `claude web extras auto disables when leaving CLI`() throws { - let suite = "ProviderSettingsDescriptorTests-claude-invariant" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-claude-invariant") + let settings = fixture.settings settings.debugMenuEnabled = true settings.claudeUsageDataSource = .cli settings.claudeWebExtrasEnabled = true @@ -337,40 +140,8 @@ struct ProviderSettingsDescriptorTests { @Test func `kilo exposes usage source picker and api field only`() throws { - let suite = "ProviderSettingsDescriptorTests-kilo" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - let context = ProviderSettingsContext( - provider: .kilo, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }, - runLoginFlow: {}) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-kilo") + let context = fixture.settingsContext(provider: .kilo) let implementation = KiloProviderImplementation() let toggles = implementation.settingsToggles(context: context) @@ -384,40 +155,8 @@ struct ProviderSettingsDescriptorTests { @Test func `deepgram exposes api key and project id fields`() throws { - let suite = "ProviderSettingsDescriptorTests-deepgram" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - - let context = ProviderSettingsContext( - provider: .deepgram, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }, - runLoginFlow: {}) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-deepgram") + let context = fixture.settingsContext(provider: .deepgram) let implementation = DeepgramProviderImplementation() let fields = implementation.settingsFields(context: context) @@ -432,75 +171,102 @@ struct ProviderSettingsDescriptorTests { @Test func `alibaba presentation follows store source label`() throws { - let suite = "ProviderSettingsDescriptorTests-alibaba-presentation" - let defaults = try #require(UserDefaults(suiteName: suite)) - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore()) - let store = UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-alibaba-presentation") let metadata = try #require(ProviderDescriptorRegistry.metadata[.alibaba]) - let context = ProviderPresentationContext( - provider: .alibaba, - settings: settings, - store: store, - metadata: metadata) + let context = fixture.presentationContext(provider: .alibaba, metadata: metadata) let detailLine = AlibabaCodingPlanProviderImplementation() .presentation(context: context) .detailLine(context) - #expect(detailLine == store.sourceLabel(for: .alibaba)) + #expect(detailLine == fixture.store.sourceLabel(for: .alibaba)) } @Test func `alibaba token plan settings expose cookie controls`() throws { - let suite = "ProviderSettingsDescriptorTests-alibaba-token-plan-settings" + let fixture = try self.makeSettingsFixture(suite: "ProviderSettingsDescriptorTests-alibaba-token-plan-settings") + fixture.settings.alibabaTokenPlanCookieSource = .manual + let context = fixture.settingsContext(provider: .alibabatokenplan) + let implementation = AlibabaTokenPlanProviderImplementation() + let pickers = implementation.settingsPickers(context: context) + let fields = implementation.settingsFields(context: context) + + #expect(pickers.contains(where: { $0.id == "alibaba-token-plan-cookie-source" })) + #expect(fields.contains(where: { $0.id == "alibaba-token-plan-cookie" })) + #expect(fields.first?.actions.contains(where: { $0.id == "alibaba-token-plan-open-dashboard" }) == true) + } + + private func makeSettingsFixture(suite: String) throws -> ProviderSettingsFixture { let defaults = try #require(UserDefaults(suiteName: suite)) defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) let settings = SettingsStore( userDefaults: defaults, - configStore: configStore, + configStore: testConfigStore(suiteName: suite), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - let context = ProviderSettingsContext( - provider: .alibabatokenplan, - settings: settings, - store: store, - boolBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - stringBinding: { keyPath in - Binding( - get: { settings[keyPath: keyPath] }, - set: { settings[keyPath: keyPath] = $0 }) - }, - statusText: { _ in nil }, - setStatusText: { _, _ in }, - lastAppActiveRunAt: { _ in nil }, - setLastAppActiveRunAt: { _, _ in }, - requestConfirmation: { _ in }) - - settings.alibabaTokenPlanCookieSource = .manual - let implementation = AlibabaTokenPlanProviderImplementation() - let pickers = implementation.settingsPickers(context: context) - let fields = implementation.settingsFields(context: context) + return ProviderSettingsFixture(settings: settings, store: store) + } - #expect(pickers.contains(where: { $0.id == "alibaba-token-plan-cookie-source" })) - #expect(fields.contains(where: { $0.id == "alibaba-token-plan-cookie" })) - #expect(fields.first?.actions.contains(where: { $0.id == "alibaba-token-plan-open-dashboard" }) == true) + private struct ProviderSettingsFixture { + let settings: SettingsStore + let store: UsageStore + private let state = ProviderSettingsContextState() + + @MainActor + func settingsContext(provider: UsageProvider) -> ProviderSettingsContext { + let settings = self.settings + let store = self.store + let state = self.state + return ProviderSettingsContext( + provider: provider, + settings: settings, + store: store, + boolBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + stringBinding: { keyPath in + Binding( + get: { settings[keyPath: keyPath] }, + set: { settings[keyPath: keyPath] = $0 }) + }, + statusText: { id in state.statusByID[id] }, + setStatusText: { id, text in + if let text { + state.statusByID[id] = text + } else { + state.statusByID.removeValue(forKey: id) + } + }, + lastAppActiveRunAt: { id in state.lastRunAtByID[id] }, + setLastAppActiveRunAt: { id, date in + if let date { + state.lastRunAtByID[id] = date + } else { + state.lastRunAtByID.removeValue(forKey: id) + } + }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + } + + @MainActor + func presentationContext(provider: UsageProvider, metadata: ProviderMetadata) -> ProviderPresentationContext { + ProviderPresentationContext( + provider: provider, + settings: self.settings, + store: self.store, + metadata: metadata) + } + } + + private final class ProviderSettingsContextState { + var statusByID: [String: String] = [:] + var lastRunAtByID: [String: Date] = [:] } } diff --git a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift index ee60e221..7c01c3e2 100644 --- a/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift +++ b/Tests/CodexBarTests/ProvidersPaneCoverageTests.swift @@ -28,72 +28,106 @@ struct ProvidersPaneCoverageTests { } @Test - func `open router menu bar metric picker shows only automatic and primary`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-openrouter-picker") - let store = Self.makeUsageStore(settings: settings) - let pane = ProvidersPane(settings: settings, store: store) + func `provider search filters display names and raw ids`() { + let providers: [UsageProvider] = [.codex, .claude, .openrouter, .deepseek] + let names: [UsageProvider: String] = [ + .codex: "Codex", + .claude: "Claude", + .openrouter: "OpenRouter", + .deepseek: "DeepSeek", + ] - let picker = pane._test_menuBarMetricPicker(for: .openrouter) - #expect(picker?.options.map(\.id) == [ - MenuBarMetricPreference.automatic.rawValue, - MenuBarMetricPreference.primary.rawValue, - ]) - #expect(picker?.options.map(\.title) == [ - "Automatic", - "Primary (API key limit)", - ]) + #expect( + ProvidersPane.filteredProviders(providers, query: " ", displayName: { names[$0] ?? $0.rawValue }) + == providers) + #expect( + ProvidersPane.filteredProviders(providers, query: "router", displayName: { names[$0] ?? $0.rawValue }) + == [.openrouter]) + #expect( + ProvidersPane.filteredProviders(providers, query: "CLA", displayName: { names[$0] ?? $0.rawValue }) + == [.claude]) + #expect( + ProvidersPane.filteredProviders(providers, query: "deepseek", displayName: { _ in "API" }) + == [.deepseek]) } @Test - func `deepseek menu bar metric picker shows balance only copy`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-deepseek-picker") - let store = Self.makeUsageStore(settings: settings) - let pane = ProvidersPane(settings: settings, store: store) + func `open router menu bar metric picker shows only automatic and primary`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-openrouter-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .openrouter) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + MenuBarMetricPreference.primary.rawValue, + ]) + #expect(picker?.options.map(\.title) == [ + "Automatic", + "Primary (API key limit)", + ]) + } + } - let picker = pane._test_menuBarMetricPicker(for: .deepseek) - #expect(picker?.options.map(\.id) == [ - MenuBarMetricPreference.automatic.rawValue, - ]) - #expect(picker?.subtitle == "Shows the DeepSeek balance in the menu bar.") + @Test + func `deepseek menu bar metric picker shows balance only copy`() { + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-deepseek-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .deepseek) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows the DeepSeek balance in the menu bar.") + } } @Test func `moonshot menu bar metric picker shows balance only copy`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-moonshot-picker") - let store = Self.makeUsageStore(settings: settings) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .moonshot) - #expect(picker?.options.map(\.id) == [ - MenuBarMetricPreference.automatic.rawValue, - ]) - #expect(picker?.subtitle == "Shows the Moonshot / Kimi API balance in the menu bar.") + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-moonshot-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .moonshot) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows the Moonshot / Kimi API balance in the menu bar.") + } } @Test func `mistral menu bar metric picker shows spend only copy`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-mistral-picker") - let store = Self.makeUsageStore(settings: settings) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .mistral) - #expect(picker?.options.map(\.id) == [ - MenuBarMetricPreference.automatic.rawValue, - ]) - #expect(picker?.subtitle == "Shows current-month Mistral API spend in the menu bar.") + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-mistral-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .mistral) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows current-month Mistral API spend in the menu bar.") + } } @Test func `kimi k2 menu bar metric picker shows credits only copy`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-kimik2-picker") - let store = Self.makeUsageStore(settings: settings) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .kimik2) - #expect(picker?.options.map(\.id) == [ - MenuBarMetricPreference.automatic.rawValue, - ]) - #expect(picker?.subtitle == "Shows Kimi K2 API-key credits in the menu bar.") + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-kimik2-picker") + let store = Self.makeUsageStore(settings: settings) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .kimik2) + #expect(picker?.options.map(\.id) == [ + MenuBarMetricPreference.automatic.rawValue, + ]) + #expect(picker?.subtitle == "Shows Kimi K2 API-key credits in the menu bar.") + } } @Test @@ -109,22 +143,24 @@ struct ProvidersPaneCoverageTests { @Test func `cursor menu bar metric picker includes tertiary api lane when snapshot has api metric`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-tertiary-picker") - let store = Self.makeUsageStore(settings: settings) - store._setSnapshotForTesting( - UsageSnapshot( - primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - updatedAt: Date()), - provider: .cursor) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .cursor) - let ids = picker?.options.map(\.id) ?? [] - #expect(ids.contains(MenuBarMetricPreference.tertiary.rawValue)) - let tertiaryOption = picker?.options.first { $0.id == MenuBarMetricPreference.tertiary.rawValue } - #expect(tertiaryOption?.title == "Tertiary (API)") + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.tertiary.rawValue)) + let tertiaryOption = picker?.options.first { $0.id == MenuBarMetricPreference.tertiary.rawValue } + #expect(tertiaryOption?.title == "Tertiary (API)") + } } @Test @@ -146,27 +182,29 @@ struct ProvidersPaneCoverageTests { @Test func `cursor menu bar metric picker includes extra usage when on demand budget is available`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-extra-usage-picker") - let store = Self.makeUsageStore(settings: settings) - store._setSnapshotForTesting( - UsageSnapshot( - primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - providerCost: ProviderCostSnapshot( - used: 15, - limit: 100, - currencyCode: "USD", + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-cursor-extra-usage-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 15, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), updatedAt: Date()), - updatedAt: Date()), - provider: .cursor) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .cursor) - let ids = picker?.options.map(\.id) ?? [] - #expect(ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) - let option = picker?.options.first { $0.id == MenuBarMetricPreference.extraUsage.rawValue } - #expect(option?.title == "Extra usage") + provider: .cursor) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .cursor) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.extraUsage.rawValue)) + let option = picker?.options.first { $0.id == MenuBarMetricPreference.extraUsage.rawValue } + #expect(option?.title == "Extra usage") + } } @Test @@ -209,22 +247,24 @@ struct ProvidersPaneCoverageTests { @Test func `zai menu bar metric picker includes tertiary 5-hour lane when snapshot has it`() { - let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-tertiary-picker") - let store = Self.makeUsageStore(settings: settings) - store._setSnapshotForTesting( - UsageSnapshot( - primary: RateWindow(usedPercent: 12, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - tertiary: RateWindow(usedPercent: 56, windowMinutes: 300, resetsAt: nil, resetDescription: nil), - updatedAt: Date()), - provider: .zai) - let pane = ProvidersPane(settings: settings, store: store) - - let picker = pane._test_menuBarMetricPicker(for: .zai) - let ids = picker?.options.map(\.id) ?? [] - #expect(ids.contains(MenuBarMetricPreference.tertiary.rawValue)) - let tertiaryOption = picker?.options.first { $0.id == MenuBarMetricPreference.tertiary.rawValue } - #expect(tertiaryOption?.title == "Tertiary (5-hour)") + Self.withEnglishLocalization { + let settings = Self.makeSettingsStore(suite: "ProvidersPaneCoverageTests-zai-tertiary-picker") + let store = Self.makeUsageStore(settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 12, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 34, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 56, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .zai) + let pane = ProvidersPane(settings: settings, store: store) + + let picker = pane._test_menuBarMetricPicker(for: .zai) + let ids = picker?.options.map(\.id) ?? [] + #expect(ids.contains(MenuBarMetricPreference.tertiary.rawValue)) + let tertiaryOption = picker?.options.first { $0.id == MenuBarMetricPreference.tertiary.rawValue } + #expect(tertiaryOption?.title == "Tertiary (5-hour)") + } } @Test @@ -240,26 +280,32 @@ struct ProvidersPaneCoverageTests { @Test func `provider detail plan row formats open router as balance`() { - let row = ProviderDetailView.planRow(provider: .openrouter, planText: "Balance: $4.61") + Self.withEnglishLocalization { + let row = ProviderDetailView.planRow(provider: .openrouter, planText: "Balance: $4.61") - #expect(row?.label == "Balance") - #expect(row?.value == "$4.61") + #expect(row?.label == "Balance") + #expect(row?.value == "$4.61") + } } @Test func `provider detail plan row formats moonshot as balance`() { - let row = ProviderDetailView.planRow(provider: .moonshot, planText: "Balance: $49.58") + Self.withEnglishLocalization { + let row = ProviderDetailView.planRow(provider: .moonshot, planText: "Balance: $49.58") - #expect(row?.label == "Balance") - #expect(row?.value == "$49.58") + #expect(row?.label == "Balance") + #expect(row?.value == "$49.58") + } } @Test func `provider detail plan row keeps plan label for non open router`() { - let row = ProviderDetailView.planRow(provider: .codex, planText: "Pro") + Self.withEnglishLocalization { + let row = ProviderDetailView.planRow(provider: .codex, planText: "Pro") - #expect(row?.label == "Plan") - #expect(row?.value == "Pro") + #expect(row?.label == "Plan") + #expect(row?.value == "Pro") + } } @Test @@ -370,6 +416,10 @@ struct ProvidersPaneCoverageTests { settings: settings) } + private static func withEnglishLocalization(perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue("en", operation: body) + } + private static func writeCodexAuthFile(homeURL: URL, email: String, plan: String) throws { try FileManager.default.createDirectory(at: homeURL, withIntermediateDirectories: true) let auth = [ diff --git a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift index 2f7f2b3b..b0bd32c7 100644 --- a/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift +++ b/Tests/CodexBarTests/QuotaWarningNotificationLogicTests.swift @@ -1,42 +1,64 @@ import Testing @testable import CodexBar +@Suite(.serialized) struct QuotaWarningNotificationLogicTests { @Test func `quota warning copy includes current remaining and threshold`() { - let copy = QuotaWarningNotificationLogic.notificationCopy( - providerName: "Codex", - window: .session, - threshold: 20, - currentRemaining: 12.4) - - #expect(copy.title == "Codex session quota low") - #expect(copy.body == "12% left. Reached your 20% session warning threshold.") + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + threshold: 20, + currentRemaining: 12.4) + + #expect(copy.title == "Codex session quota low") + #expect(copy.body == "12% left. Reached your 20% session warning threshold.") + } } @Test func `quota warning copy clamps current remaining`() { - let copy = QuotaWarningNotificationLogic.notificationCopy( - providerName: "Codex", - window: .weekly, - threshold: 50, - currentRemaining: -3) - - #expect(copy.title == "Codex weekly quota low") - #expect(copy.body == "0% left. Reached your 50% weekly warning threshold.") + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .weekly, + threshold: 50, + currentRemaining: -3) + + #expect(copy.title == "Codex weekly quota low") + #expect(copy.body == "0% left. Reached your 50% weekly warning threshold.") + } } @Test func `quota warning copy includes account when provided`() { - let copy = QuotaWarningNotificationLogic.notificationCopy( - providerName: "Codex", - window: .session, - threshold: 50, - currentRemaining: 45, - accountDisplayName: "person@example.com") + Self.withAppLanguage("en") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + threshold: 50, + currentRemaining: 45, + accountDisplayName: "person@example.com") + + #expect(copy.title == "Codex session quota low") + #expect(copy.body == "Account person@example.com. 45% left. Reached your 50% session warning threshold.") + } + } - #expect(copy.title == "Codex session quota low") - #expect(copy.body == "Account person@example.com. 45% left. Reached your 50% session warning threshold.") + @Test + func `quota warning copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = QuotaWarningNotificationLogic.notificationCopy( + providerName: "Codex", + window: .session, + threshold: 50, + currentRemaining: 45, + accountDisplayName: "person@example.com") + + #expect(copy.title == "Codex 工作階段配額偏低") + #expect(copy.body == "帳號 person@example.com。剩餘 45%。已達到 50% 工作階段提醒門檻。") + } } @Test @@ -123,4 +145,8 @@ struct QuotaWarningNotificationLogicTests { #expect(crossed == nil) #expect(QuotaWarningNotificationLogic.firedThresholdsAfterWarning(threshold: 10, thresholds: [10, 0]) == [10]) } + + private static func withAppLanguage(_ language: String, perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue(language, operation: body) + } } diff --git a/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift b/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift index 5b0024fa..2c43d317 100644 --- a/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift +++ b/Tests/CodexBarTests/SessionQuotaNotificationLogicTests.swift @@ -1,6 +1,7 @@ import Testing @testable import CodexBar +@Suite(.serialized) struct SessionQuotaNotificationLogicTests { @Test func `does nothing without previous value`() { @@ -32,4 +33,32 @@ struct SessionQuotaNotificationLogicTests { let transition = SessionQuotaNotificationLogic.transition(previousRemaining: 0, currentRemaining: 0.00001) #expect(transition == .none) } + + @Test + func `depleted notification copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = SessionQuotaNotificationLogic.notificationCopy( + transition: .depleted, + providerName: "Codex") + + #expect(copy.title == "Codex 工作階段已用完") + #expect(copy.body == "剩餘 0%。恢復可用時會再通知。") + } + } + + @Test + func `restored notification copy follows Traditional Chinese app language`() { + Self.withAppLanguage("zh-Hant") { + let copy = SessionQuotaNotificationLogic.notificationCopy( + transition: .restored, + providerName: "Codex") + + #expect(copy.title == "Codex 工作階段已恢復") + #expect(copy.body == "工作階段配額已恢復可用。") + } + } + + private static func withAppLanguage(_ language: String, perform body: () -> Void) { + CodexBarLocalizationOverride.$appLanguage.withValue(language, operation: body) + } } diff --git a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift index 36079a78..44a3e3e4 100644 --- a/Tests/CodexBarTests/SettingsStoreCoverageTests.swift +++ b/Tests/CodexBarTests/SettingsStoreCoverageTests.swift @@ -485,6 +485,38 @@ struct SettingsStoreCoverageTests { #expect(settings.selectedTokenAccount(for: .antigravity)?.id == accounts.last?.id) } + @Test + func `weekly progress work days defaults to nil and persists across store reload`() throws { + let suite = "SettingsStoreCoverageTests-weekly-progress-work-days" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let fresh = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(fresh.weeklyProgressWorkDays == nil) + + fresh.weeklyProgressWorkDays = 5 + #expect(defaults.object(forKey: "weeklyProgressWorkDays") as? Int == 5) + + let reloaded = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded.weeklyProgressWorkDays == 5) + + fresh.weeklyProgressWorkDays = 4 + #expect(reloaded.weeklyProgressWorkDays == 5) + + let reloaded2 = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded2.weeklyProgressWorkDays == 4) + + reloaded2.weeklyProgressWorkDays = 7 + let reloaded3 = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded3.weeklyProgressWorkDays == 7) + + reloaded3.weeklyProgressWorkDays = nil + #expect(defaults.object(forKey: "weeklyProgressWorkDays") == nil) + let reloaded4 = Self.makeSettingsStore(userDefaults: defaults, configStore: configStore) + #expect(reloaded4.weeklyProgressWorkDays == nil) + } + private static func makeSettingsStore(suiteName: String = "SettingsStoreCoverageTests") -> SettingsStore { let defaults = UserDefaults(suiteName: suiteName)! defaults.removePersistentDomain(forName: suiteName) diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 4182f310..7c3b7079 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -1068,6 +1068,33 @@ struct SettingsStoreTests { await expectObservation(for: .weekly, thresholds: [80, 40]) } + @Test + func `menu observation token updates on weekly progress work days changes`() async throws { + let suite = "SettingsStoreTests-observation-weekly-progress-work-days" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + + let store = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + + let didChange = ObservationFlag() + + withObservationTracking { + _ = store.menuObservationToken + } onChange: { + didChange.set() + } + + store.weeklyProgressWorkDays = 5 + try? await Task.sleep(nanoseconds: 50_000_000) + + #expect(didChange.get() == true) + } + @Test func `config backed settings trigger observation`() async throws { let suite = "SettingsStoreTests-observation-config" diff --git a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift index fc064a9d..dfef3960 100644 --- a/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationSignatureTests.swift @@ -77,6 +77,115 @@ struct StatusItemAnimationSignatureTests { #expect(codexSignature?.contains("style=codex") == true) } + @Test + func `merged brand percent reapplies title when cached render is skipped`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-brand-percent-title-restore" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.menuBarShowsBrandIconWithPercent = true + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = false + settings.syntheticAPIToken = "synthetic-test-token" + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 23, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + + let displayText = try #require(controller.menuBarDisplayText(for: .codex, snapshot: snapshot)) + let expectedTitle = StatusItemController.buttonTitle(displayText, hasImage: true) + controller.applyIcon(phase: nil) + let button = try #require(controller.statusItem.button) + #expect(button.title == expectedTitle) + #expect(button.imagePosition == .imageLeft) + + button.title = "" + button.imagePosition = .imageOnly + + let skipped = controller.applyIcon(phase: nil) + + #expect(skipped) + #expect(button.title == expectedTitle) + #expect(button.imagePosition == .imageLeft) + } + + @Test + func `merged fallback provider follows enabled provider order`() throws { + let suite = "StatusItemAnimationSignatureTests-merged-provider-order" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.menuBarShowsBrandIconWithPercent = false + settings.syntheticAPIToken = "synthetic-test-token" + settings.setProviderOrder([.synthetic, .codex]) + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let syntheticMeta = registry.metadata[.synthetic] { + settings.setProviderEnabled(provider: .synthetic, metadata: syntheticMeta, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store._setSnapshotForTesting(snapshot, provider: .codex) + store._setSnapshotForTesting(snapshot, provider: .synthetic) + + controller.applyIcon(phase: nil) + + #expect(store.enabledProviders().prefix(2) == [.synthetic, .codex]) + #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=synthetic") == true) + } + @Test func `merged icon follows overview provider order when first overview provider is loading`() { let suite = "StatusItemAnimationSignatureTests-merged-overview-provider-order" diff --git a/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift new file mode 100644 index 00000000..73a42736 --- /dev/null +++ b/Tests/CodexBarTests/StatusItemControllerShutdownTests.swift @@ -0,0 +1,71 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusItemControllerShutdownTests { + @Test + func `app shutdown closes tracked menus and removes status items`() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = !SettingsStore.isRunningTests + StatusItemController.resetMenuRefreshEnabledForTesting() + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + let registry = ProviderRegistry.shared + if let codexMetadata = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMetadata, enabled: true) + } + if let claudeMetadata = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMetadata, enabled: true) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.menuRefreshTasks[key] = Task { try? await Task.sleep(for: .seconds(30)) } + + #expect(controller.openMenus[key] === menu) + #expect(controller.statusItem.menu != nil) + + controller.prepareForAppShutdown() + controller.prepareForAppShutdown() + + #expect(controller.hasPreparedForAppShutdown) + #expect(controller.openMenus.isEmpty) + #expect(controller.menuRefreshTasks.isEmpty) + #expect(controller.providerSwitcherShortcutEventMonitor == nil) + #expect(controller.statusItem.menu == nil) + #expect(controller.statusItems.isEmpty) + #expect(controller.providerMenus.isEmpty) + #expect(controller.mergedMenu == nil) + } + + private func makeSettings() -> SettingsStore { + let suite = "StatusItemControllerShutdownTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } +} diff --git a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift index bbfb8230..491afc42 100644 --- a/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift +++ b/Tests/CodexBarTests/StatusItemControllerSplitLifecycleTests.swift @@ -97,6 +97,275 @@ struct StatusItemControllerSplitLifecycleTests { #expect(!self.containsHostingView(mergedButton)) } + @Test + func `status items publish stable manager identity`() throws { + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let codexButton = try #require(controller.statusItems[.codex]?.button) + let claudeButton = try #require(controller.statusItems[.claude]?.button) + + #expect(controller.statusItem.autosaveName == "codexbar-merged") + #expect(controller.statusItems[.codex]?.autosaveName == "codexbar-codex") + #expect(controller.statusItems[.claude]?.autosaveName == "codexbar-claude") + #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") + #expect(codexButton.accessibilityIdentifier() == "CodexBar.StatusItem.codex") + #expect(claudeButton.accessibilityIdentifier() == "CodexBar.StatusItem.claude") + #expect(controller.statusItem.button?.accessibilityTitle() == "CodexBar") + #expect(codexButton.accessibilityTitle() == "CodexBar") + #expect(claudeButton.accessibilityTitle() == "CodexBar") + } + + @Test + func `status item identity returns stable autosave names`() { + #expect(StatusItemController.StatusItemIdentity.merged.autosaveName == "codexbar-merged") + #expect(StatusItemController.StatusItemIdentity.provider(.codex).autosaveName == "codexbar-codex") + #expect(StatusItemController.StatusItemIdentity.provider(.claude).autosaveName == "codexbar-claude") + } + + @Test + func `status item placement preflight writes low position on fresh install`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-missing-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + + #expect(MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + #expect(defaults.double(forKey: key) == 0) + } + + @Test + func `status item placement preflight preserves missing new key when legacy item placement exists`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + legacyDefaultItemIndex: 0)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + } + + @Test + func `status item placement preflight repairs missing new key when legacy item placement is suspicious`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-high-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-0") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + legacyDefaultItemIndex: 0)) + + #expect(defaults.double(forKey: key) == 0) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 11298) + } + + @Test + func `status item placement preflight preserves missing new key when mixed legacy placements exist`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-legacy-mixed-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-1") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-merged", + legacyDefaultItemIndex: 0)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-1") == 11298) + } + + @Test + func `status item placement preflight repairs provider new key when mixed legacy placements exist`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-mixed-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + defaults.set(11298, forKey: "NSStatusItem Preferred Position Item-1") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-codex", + legacyDefaultItemIndex: 1)) + + #expect(defaults.double(forKey: key) == 0) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-1") == 11298) + } + + @Test + func `status item placement preflight repairs provider new key when only merged legacy placement exists`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-single-legacy-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + + #expect(MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-codex", + legacyDefaultItemIndex: 1)) + + #expect(defaults.double(forKey: key) == 0) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + } + + @Test + func `status item placement preflight preserves provider key with matching legacy placement`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-provider-matching-legacy-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(42, forKey: "NSStatusItem Preferred Position Item-0") + defaults.set(58, forKey: "NSStatusItem Preferred Position Item-1") + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-codex") + + #expect(!MenuBarStatusItemPlacementPreflight.prepare( + defaults: defaults, + autosaveName: "codexbar-codex", + legacyDefaultItemIndex: 1)) + + #expect(defaults.object(forKey: key) == nil) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-0") == 42) + #expect(defaults.double(forKey: "NSStatusItem Preferred Position Item-1") == 58) + } + + @Test + func `status item placement preflight replaces suspicious high position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-high-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(11298, forKey: key) + + #expect(MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + + #expect(defaults.double(forKey: key) == 0) + } + + @Test + func `status item placement preflight preserves reasonable position`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-placement-preserve-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + let key = MenuBarStatusItemPlacementPreflight.preferredPositionKey(autosaveName: "codexbar-merged") + defaults.set(42, forKey: key) + + #expect(!MenuBarStatusItemPlacementPreflight.prepare(defaults: defaults, autosaveName: "codexbar-merged")) + + #expect(defaults.double(forKey: key) == 42) + } + + @Test + func `status item defaults repair removes stale hidden Control Center keys once`() throws { + let suite = "StatusItemControllerSplitLifecycleTests-repair-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + defaults.set(false, forKey: "NSStatusItem VisibleCC Item-0") + defaults.set(0, forKey: "NSStatusItem VisibleCC Item-12") + defaults.set(false, forKey: "NSStatusItem VisibleCC codexbar-merged") + defaults.set(true, forKey: "NSStatusItem VisibleCC Item-1") + defaults.set(false, forKey: "NSStatusItem VisibleCC com.apple.clock") + defer { + defaults.removePersistentDomain(forName: suite) + } + + let repairedKeys = MenuBarStatusItemDefaultsRepair.repairHiddenVisibilityDefaultsIfNeeded(defaults: defaults) + + #expect(repairedKeys == [ + "NSStatusItem VisibleCC Item-0", + "NSStatusItem VisibleCC Item-12", + "NSStatusItem VisibleCC codexbar-merged", + ]) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-0") == nil) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-12") == nil) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC codexbar-merged") == nil) + #expect(defaults.bool(forKey: "NSStatusItem VisibleCC Item-1")) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC com.apple.clock") != nil) + + defaults.set(false, forKey: "NSStatusItem VisibleCC Item-2") + #expect(MenuBarStatusItemDefaultsRepair.repairHiddenVisibilityDefaultsIfNeeded(defaults: defaults).isEmpty) + #expect(defaults.object(forKey: "NSStatusItem VisibleCC Item-2") != nil) + } + + @Test + func `non destructive visibility refresh preserves split provider status items`() throws { + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + let oldCodexItem = try #require(controller.statusItems[.codex]) + let oldClaudeItem = try #require(controller.statusItems[.claude]) + let oldCodexButton = try #require(oldCodexItem.button) + + controller.refreshExistingStatusItemsForVisibilityRecovery() + + let newCodexItem = try #require(controller.statusItems[.codex]) + let newClaudeItem = try #require(controller.statusItems[.claude]) + #expect(newCodexItem === oldCodexItem) + #expect(newClaudeItem === oldClaudeItem) + #expect(newCodexItem.button === oldCodexButton) + #expect(newCodexItem.autosaveName == "codexbar-codex") + #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") + } + + @Test + func `non destructive visibility refresh preserves merged status item`() throws { + let (settings, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + settings.mergeIcons = true + controller.handleProviderConfigChange(reason: "test") + let oldMergedItem = controller.statusItem + let oldMergedButton = try #require(controller.statusItem.button) + + controller.refreshExistingStatusItemsForVisibilityRecovery() + + #expect(controller.statusItem === oldMergedItem) + #expect(controller.statusItem.button === oldMergedButton) + #expect(controller.statusItem.autosaveName == "codexbar-merged") + #expect(controller.statusItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem") + } + + @Test + func `recreation produces immediately healthy snapshots for synchronous guidance check`() throws { + // verifyScreenChangeRecoveryIfNeeded does a synchronous re-check immediately after + // the single recreation to decide whether to show macOS 26 Allow-in-Menu-Bar guidance. + // AppKit must materialise the button and window before returning from + // recreateStatusItemsForVisibilityRecovery, so the item must not appear blocked at + // that point. Only a genuine system-level block would leave it blocked — which is + // exactly the case where guidance is useful. + let (_, controller) = try self.makeSplitController() + defer { controller.releaseStatusItemsForTesting() } + + controller.recreateStatusItemsForVisibilityRecovery() + + let allItems = [controller.statusItem] + Array(controller.statusItems.values) + let snapshots = MenuBarVisibilityWatcher.visibilitySnapshots(allItems) + #expect(!MenuBarVisibilityWatcher.hasAnyBlockedVisibleSnapshot(snapshots)) + } + @Test func `visibility recovery recreates split provider status items`() throws { let (_, controller) = try self.makeSplitController() @@ -107,6 +376,8 @@ struct StatusItemControllerSplitLifecycleTests { let newCodexItem = try #require(controller.statusItems[.codex]) #expect(newCodexItem !== oldCodexItem) + #expect(newCodexItem.autosaveName == "codexbar-codex") + #expect(newCodexItem.button?.accessibilityIdentifier() == "CodexBar.StatusItem.codex") } @Test @@ -123,5 +394,7 @@ struct StatusItemControllerSplitLifecycleTests { let mergedButton = try #require(controller.statusItem.button) #expect(mergedButton.image != nil) + #expect(controller.statusItem.autosaveName == "codexbar-merged") + #expect(mergedButton.accessibilityIdentifier() == "CodexBar.StatusItem") } } diff --git a/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift index 34b9e374..3ef14df7 100644 --- a/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift +++ b/Tests/CodexBarTests/StatusItemExtraUsageMetricTests.swift @@ -38,7 +38,9 @@ struct StatusItemExtraUsageMetricTests { @Test func `menu bar extra usage preference falls back to automatic when cursor on demand budget is missing`() { - let (store, controller) = self.makeCursorController(suiteName: "StatusItemExtraUsageMetricTests-missing-budget") + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-missing-budget", + provider: .cursor) let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -54,19 +56,94 @@ struct StatusItemExtraUsageMetricTests { #expect(window?.usedPercent == 72) } + @Test + func `menu bar extra usage preference shows currency spend text for cursor when provider cost exists`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-cursor-spend-text", + provider: .cursor) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + providerCost: ProviderCostSnapshot( + used: 12.34, + limit: 100, + currencyCode: "USD", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "$12.34") + } + + @Test + func `menu bar extra usage preference shows currency spend text for claude when provider cost exists`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-claude-spend-text", + provider: .claude) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 42, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + tertiary: nil, + providerCost: ProviderCostSnapshot( + used: 88.8, + limit: 200, + currencyCode: "USD", + period: "Monthly", + updatedAt: Date()), + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .claude) + store._setErrorForTesting(nil, provider: .claude) + + let displayText = controller.menuBarDisplayText(for: .claude, snapshot: snapshot) + + #expect(displayText == "$88.80") + } + + @Test + func `menu bar extra usage preference falls back to existing percent text when provider cost is unavailable`() { + let (store, controller) = self.makeController( + suiteName: "StatusItemExtraUsageMetricTests-fallback-percent", + provider: .cursor) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 72, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: nil, + providerCost: nil, + updatedAt: Date()) + + store._setSnapshotForTesting(snapshot, provider: .cursor) + store._setErrorForTesting(nil, provider: .cursor) + + let displayText = controller.menuBarDisplayText(for: .cursor, snapshot: snapshot) + + #expect(displayText == "72%") + } + private func makeCursorController(suiteName: String) -> (UsageStore, StatusItemController) { + self.makeController(suiteName: suiteName, provider: .cursor) + } + + private func makeController(suiteName: String, provider: UsageProvider) -> (UsageStore, StatusItemController) { let settings = SettingsStore( configStore: testConfigStore(suiteName: suiteName), zaiTokenStore: NoopZaiTokenStore()) settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true - settings.selectedMenuProvider = .cursor - settings.setMenuBarMetricPreference(.extraUsage, for: .cursor) + settings.selectedMenuProvider = provider + settings.menuBarDisplayMode = .percent + settings.usageBarsShowUsed = true + settings.setMenuBarMetricPreference(.extraUsage, for: provider) let registry = ProviderRegistry.shared - if let cursorMeta = registry.metadata[.cursor] { - settings.setProviderEnabled(provider: .cursor, metadata: cursorMeta, enabled: true) + if let metadata = registry.metadata[provider] { + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: true) } let fetcher = UsageFetcher() diff --git a/Tests/CodexBarTests/StatusMenuHighlightTests.swift b/Tests/CodexBarTests/StatusMenuHighlightTests.swift new file mode 100644 index 00000000..96fccbb5 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuHighlightTests.swift @@ -0,0 +1,55 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +extension StatusMenuTests { + final class HighlightProbeView: NSView, MenuCardHighlighting { + private(set) var states: [Bool] = [] + + func setHighlighted(_ highlighted: Bool) { + self.states.append(highlighted) + } + } + + @Test + func `menu highlight updates only previous and current custom rows`() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = NSMenu() + let firstView = HighlightProbeView() + let secondView = HighlightProbeView() + let thirdView = HighlightProbeView() + let first = NSMenuItem() + first.view = firstView + first.isEnabled = true + let second = NSMenuItem() + second.view = secondView + second.isEnabled = true + let third = NSMenuItem() + third.view = thirdView + third.isEnabled = true + menu.addItem(first) + menu.addItem(second) + menu.addItem(third) + + controller.menu(menu, willHighlight: first) + controller.menu(menu, willHighlight: second) + controller.menu(menu, willHighlight: second) + + #expect(firstView.states == [true, false]) + #expect(secondView.states == [true]) + #expect(thirdView.states.isEmpty) + } +} diff --git a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift index 845c2ab6..a528c5c5 100644 --- a/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuHostedSubmenuRefreshTests.swift @@ -7,14 +7,11 @@ import Testing @Suite(.serialized) struct StatusMenuHostedSubmenuRefreshTests { @Test - func `open parent menu defers data rebuild until next open`() throws { + func `open parent menu defers data rebuild until parent tracking ends`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled - let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.menuCardRenderingEnabled = true - StatusItemController.setMenuRefreshEnabledForTesting(false) defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering - StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } let settings = Self.makeSettings() @@ -37,6 +34,7 @@ struct StatusMenuHostedSubmenuRefreshTests { preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = false let menu = controller.makeMenu() controller.menuWillOpen(menu) @@ -54,26 +52,180 @@ struct StatusMenuHostedSubmenuRefreshTests { #expect(submenu.minimumWidth >= StatusItemController.menuCardBaseWidth) #expect(submenu.items.first?.view == nil) - StatusItemController.setMenuRefreshEnabledForTesting(true) + controller.menuRefreshEnabledOverrideForTesting = true controller.menuWillOpen(submenu) let submenuKey = ObjectIdentifier(submenu) #expect(controller.openMenus[submenuKey] === submenu) #expect(submenu.items.first?.view != nil) let oldParentVersion = try #require(controller.menuVersions[parentKey]) - controller.menuContentVersion &+= 1 - controller.refreshOpenMenusIfNeeded() + controller.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true) + #expect(controller.menuVersions[parentKey] == oldParentVersion) + controller.invalidateMenus( + refreshOpenMenus: true, + deferOpenParentMenuRebuild: true) #expect(controller.menuVersions[parentKey] == oldParentVersion) controller.menuDidClose(submenu) #expect(controller.openMenus[submenuKey] == nil) + for _ in 0..<40 where controller.menuVersions[parentKey] != oldParentVersion { + await Task.yield() + } #expect(controller.menuVersions[parentKey] == oldParentVersion) + controller.menuDidClose(menu) - controller.menuWillOpen(menu) + for _ in 0..<40 where controller.menuVersions[parentKey] != controller.menuContentVersion { + await Task.yield() + } + if controller.menuVersions[parentKey] != controller.menuContentVersion { + controller.menuWillOpen(menu) + } + for _ in 0..<40 where controller.menuVersions[parentKey] != controller.menuContentVersion { + await Task.yield() + } #expect(controller.menuVersions[parentKey] == controller.menuContentVersion) } + @Test + func `open hosted submenu rebuilds from unavailable placeholder when data arrives`() async { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.costUsageEnabled = true + Self.enableOnlyClaude(settings) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.costHistoryChartID, + provider: .claude, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + let submenuKey = ObjectIdentifier(submenu) + #expect(controller.openMenus[submenuKey] === submenu) + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.items.first?.view == nil) + #expect(submenu.items.first?.title == "No data available") + + let openedVersion = controller.menuContentVersion + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) + controller.invalidateMenus(refreshOpenMenus: true) + + for _ in 0..<40 { + if controller.menuContentVersion != openedVersion, + submenu.items.first?.view != nil + { + break + } + await Task.yield() + } + + #expect(controller.menuContentVersion != openedVersion) + #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(submenu.items.first?.view != nil) + #expect(submenu.items.first?.title != "No data available") + } + + @Test + func `open hydrated provider submenu preserves identity across refresh`() throws { + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.costHistoryChartID, + provider: .claude, + seed: Self.seedClaudeSnapshots) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.costHistoryChartID, + provider: .openai, + seed: Self.seedOpenAICostSnapshot) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.usageHistoryChartID, + provider: .claude, + seed: Self.seedPlanUtilizationHistory) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.storageBreakdownID, + provider: .claude, + seed: Self.seedStorageFootprint) + try self.assertHostedSubmenuPreservesIdentity( + chartID: StatusItemController.zaiHourlyUsageChartID, + provider: .zai, + seed: Self.seedZaiHourlyUsage) + } + + private func assertHostedSubmenuPreservesIdentity( + chartID: String, + provider: UsageProvider, + seed: (UsageStore) -> Void) throws + { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = provider + settings.costUsageEnabled = true + settings.providerStorageFootprintsEnabled = true + Self.enableOnly(settings, provider: provider) + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + seed(store) + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + defer { controller.releaseStatusItemsForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: chartID, + provider: provider, + width: StatusItemController.menuCardBaseWidth) + controller.menuWillOpen(submenu) + + let hydratedItem = try #require(submenu.items.first) + #expect(hydratedItem.representedObject as? String == chartID) + #expect(hydratedItem.toolTip == provider.rawValue) + #expect(hydratedItem.view != nil) + #expect(hydratedItem.title != "No data available") + + controller.refreshHostedSubviewMenu(submenu) + + let refreshedItem = try #require(submenu.items.first) + #expect(refreshedItem.representedObject as? String == chartID) + #expect(refreshedItem.toolTip == provider.rawValue) + #expect(refreshedItem.view != nil) + #expect(refreshedItem.title != "No data available") + } + private static func makeSettings() -> SettingsStore { let suite = "StatusMenuHostedSubmenuRefreshTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -86,12 +238,14 @@ struct StatusMenuHostedSubmenuRefreshTests { } private static func enableOnlyClaude(_ settings: SettingsStore) { + self.enableOnly(settings, provider: .claude) + } + + private static func enableOnly(_ settings: SettingsStore, provider enabledProvider: UsageProvider) { let registry = ProviderRegistry.shared - if let codexMeta = registry.metadata[.codex] { - settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: false) - } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == enabledProvider) } } @@ -107,7 +261,96 @@ struct StatusMenuHostedSubmenuRefreshTests { accountOrganization: nil, loginMethod: "Team")) store._setSnapshotForTesting(snapshot, provider: .claude) - store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) + } + + private static func seedOpenAICostSnapshot(in store: UsageStore) { + let day = Date(timeIntervalSince1970: 1_700_000_000) + let apiUsage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2025-12-23", + startTime: day, + endTime: day.addingTimeInterval(86400), + costUSD: 1.23, + requests: 12, + inputTokens: 100, + cachedInputTokens: 20, + outputTokens: 40, + totalTokens: 160, + lineItems: [], + models: []), + ], + updatedAt: Date(timeIntervalSince1970: 1_700_086_400)) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + openAIAPIUsage: apiUsage, + updatedAt: Date(timeIntervalSince1970: 1_700_086_400), + identity: ProviderIdentitySnapshot( + providerID: .openai, + accountEmail: "openai@example.com", + accountOrganization: nil, + loginMethod: "API")) + store._setSnapshotForTesting(snapshot, provider: .openai) + } + + private static func seedPlanUtilizationHistory(in store: UsageStore) { + self.seedClaudeSnapshots(in: store) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + unscoped: [ + PlanUtilizationSeriesHistory( + name: .session, + windowMinutes: 300, + entries: [ + PlanUtilizationHistoryEntry( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + usedPercent: 24, + resetsAt: Date(timeIntervalSince1970: 1_700_018_000)), + ]), + ]) + } + + private static func seedStorageFootprint(in store: UsageStore) { + let root = "/Users/test/.claude" + store.providerStorageFootprints[.claude] = ProviderStorageFootprint( + provider: .claude, + totalBytes: 1024, + paths: [root], + missingPaths: [], + unreadablePaths: [], + components: [.init(path: "\(root)/projects", totalBytes: 1024)], + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + } + + private static func seedZaiHourlyUsage(in store: UsageStore) { + let modelUsage = ZaiModelUsageData( + xTime: ["2026-05-26 00:00"], + modelDataList: [ + ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [512]), + ]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + tertiary: nil, + zaiUsage: ZaiUsageSnapshot( + tokenLimit: nil, + timeLimit: nil, + planName: "Pro", + modelUsage: modelUsage, + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000), + identity: ProviderIdentitySnapshot( + providerID: .zai, + accountEmail: "zai@example.com", + accountOrganization: nil, + loginMethod: "OAuth")) + store._setSnapshotForTesting(snapshot, provider: .zai) + } + + private static func makeTokenSnapshot() -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( sessionTokens: 123, sessionCostUSD: 0.12, last30DaysTokens: 123, @@ -122,6 +365,6 @@ struct StatusMenuHostedSubmenuRefreshTests { modelsUsed: nil, modelBreakdowns: nil), ], - updatedAt: Date()), provider: .claude) + updatedAt: Date()) } } diff --git a/Tests/CodexBarTests/StatusMenuLocalizationRefreshTests.swift b/Tests/CodexBarTests/StatusMenuLocalizationRefreshTests.swift new file mode 100644 index 00000000..4a78b364 --- /dev/null +++ b/Tests/CodexBarTests/StatusMenuLocalizationRefreshTests.swift @@ -0,0 +1,134 @@ +import AppKit +import CodexBarCore +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct StatusMenuLocalizationRefreshTests { + @Test + func `open merged menu refreshes localized switcher and cost title when language changes`() async { + let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage") + let previousAppleLanguages = UserDefaults.standard.object(forKey: "AppleLanguages") + defer { + if let previousLanguage { + UserDefaults.standard.set(previousLanguage, forKey: "appLanguage") + } else { + UserDefaults.standard.removeObject(forKey: "appLanguage") + } + if let previousAppleLanguages { + UserDefaults.standard.set(previousAppleLanguages, forKey: "AppleLanguages") + } else { + UserDefaults.standard.removeObject(forKey: "AppleLanguages") + } + } + + Self.disableMenuCardsForTesting() + let settings = Self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.switcherShowsIcons = false + settings.selectedMenuProvider = .codex + settings.costUsageEnabled = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setTokenSnapshotForTesting(CostUsageTokenSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 123, + last30DaysCostUSD: 1.23, + daily: [ + CostUsageDailyReport.Entry( + date: "2025-12-23", + inputTokens: nil, + outputTokens: nil, + totalTokens: 123, + costUSD: 1.23, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: Date()), provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: Self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + CodexBarLocalizationOverride.$appLanguage.withValue("es") { + controller.menuWillOpen(menu) + } + controller.openMenus[ObjectIdentifier(menu)] = menu + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + + #expect(Self.switcherButtons(in: menu).first?.title == "Resumen") + #expect(menu.items.first(where: { $0.representedObject as? String == "menuCardCost" })?.title == "Coste") + + let initialSwitcher = menu.items.first?.view as? ProviderSwitcherView + let initialSwitcherID = initialSwitcher.map(ObjectIdentifier.init) + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + settings.appLanguage = "en" + controller.handleProviderConfigChange(reason: "appLanguage") + } + + for _ in 0..<100 where rebuildCount == 0 { + await Task.yield() + try? await Task.sleep(for: .milliseconds(10)) + } + + #expect(rebuildCount == 1) + let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView + #expect(Self.switcherButtons(in: menu).first?.title == "Overview") + #expect(menu.items.first(where: { $0.representedObject as? String == "menuCardCost" })?.title == "Cost") + if let initialSwitcherID, let updatedSwitcher { + #expect(initialSwitcherID != ObjectIdentifier(updatedSwitcher)) + } + } + + private static func disableMenuCardsForTesting() { + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + } + + private static func makeStatusBarForTesting() -> NSStatusBar { + .system + } + + private static func makeSettings() -> SettingsStore { + let suite = "StatusMenuLocalizationRefreshTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + return SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + } + + private static func switcherButtons(in menu: NSMenu) -> [NSButton] { + guard let switcherView = menu.items.first?.view as? ProviderSwitcherView else { return [] } + return switcherView.subviews + .compactMap { $0 as? NSButton } + .sorted { $0.tag < $1.tag } + } +} diff --git a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift index c1871ebf..b00caa72 100644 --- a/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift +++ b/Tests/CodexBarTests/StatusMenuOpenRefreshTests.swift @@ -1,9 +1,61 @@ +import AppKit import CodexBarCore import Foundation import Testing @testable import CodexBar extension StatusMenuTests { + @Test + func `menu open defers automatic refresh until tracking ends`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + var providerRefreshCount = 0 + var refreshInteractions: [ProviderInteraction] = [] + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + refreshInteractions.append(ProviderInteractionContext.current) + providerRefreshCount += 1 + } + defer { store._test_providerRefreshOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + for _ in 0..<20 { + await Task.yield() + } + #expect(providerRefreshCount == 0) + #expect(controller.deferredMenuInteractionRefreshPending) + + controller.menuDidClose(menu) + for _ in 0..<40 where providerRefreshCount == 0 { + await Task.yield() + } + + #expect(providerRefreshCount == 1) + #expect(refreshInteractions == [.background]) + #expect(!controller.deferredMenuInteractionRefreshPending) + } + @Test func `store observation marks open menu stale without rebuilding during tracking`() async { self.disableMenuCardsForTesting() @@ -26,8 +78,7 @@ extension StatusMenuTests { controller.menuWillOpen(menu) let key = ObjectIdentifier(menu) controller.openMenus[key] = menu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true let openedVersion = controller.menuVersions[key] var rebuildCount = 0 @@ -63,7 +114,7 @@ extension StatusMenuTests { } @Test - func `explicit store actions refresh a visible open menu`() { + func `explicit store actions refresh a visible open menu`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -84,14 +135,991 @@ extension StatusMenuTests { controller.menuWillOpen(menu) let key = ObjectIdentifier(menu) controller.openMenus[key] = menu - StatusItemController.setMenuRefreshEnabledForTesting(true) - defer { StatusItemController.resetMenuRefreshEnabledForTesting() } + controller.menuRefreshEnabledOverrideForTesting = true let openedVersion = controller.menuVersions[key] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } controller.refreshOpenMenusAfterExplicitStoreAction() + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } #expect(controller.menuContentVersion != openedVersion) + #expect(rebuildCount == 1) + #expect(controller.menuVersions[key] != openedVersion) + } + + @Test + func `repeated explicit store actions coalesce to one open menu rebuild`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.refreshOpenMenusAfterExplicitStoreAction() + controller.refreshOpenMenusAfterExplicitStoreAction() + controller.refreshOpenMenusAfterExplicitStoreAction() + + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(rebuildCount == 1) #expect(controller.menuVersions[key] == controller.menuContentVersion) } + + @Test + func `explicit refresh rebuilds stale parent after hosted submenu closes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageBreakdownChartID, + provider: .codex) + let submenuKey = ObjectIdentifier(submenu) + controller.openMenus[submenuKey] = submenu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = controller.menuVersions[menuKey] + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.refreshOpenMenusAfterExplicitStoreAction() + for _ in 0..<20 where controller.menuContentVersion == openedVersion { + await Task.yield() + } + #expect(controller.menuVersions[menuKey] == openedVersion) + + controller.menuDidClose(submenu) + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 1) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + } + + @Test + func `plain open menu refresh preserves pending switcher hosted submenu cleanup`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let menuKey = ObjectIdentifier(menu) + controller.openMenus[menuKey] = menu + + let submenu = controller.makeHostedSubviewPlaceholderMenu( + chartID: StatusItemController.usageBreakdownChartID, + provider: .codex) + let submenuKey = ObjectIdentifier(submenu) + controller.openMenus[submenuKey] = submenu + controller.menuRefreshEnabledOverrideForTesting = true + + var rebuildCount = 0 + controller._test_openMenuRebuildObserver = { _ in + rebuildCount += 1 + } + defer { controller._test_openMenuRebuildObserver = nil } + + controller.deferSwitcherMenuRebuildIfStillVisible(menu, provider: .codex) + controller.refreshOpenMenuIfStillVisible(menu, provider: .codex) + + for _ in 0..<20 where rebuildCount == 0 { + await Task.yield() + } + + #expect(controller.openMenus[submenuKey] == nil) + #expect(rebuildCount == 1) + #expect(controller.menuVersions[menuKey] == controller.menuContentVersion) + } + + @Test + func `codex parent menu open defers stale OpenAI web refresh until tracking ends`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 25, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + var refreshInteractions: [ProviderInteraction] = [] + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + refreshInteractions.append(ProviderInteractionContext.current) + return try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + for _ in 0..<20 { + await Task.yield() + } + #expect(await blocker.startedCount() == 0) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + controller.menuDidClose(menu) + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + #expect(refreshInteractions == [.background]) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `programmatic parent menu close schedules deferred OpenAI web refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + store._test_codexCreditsLoaderOverride = { + CreditsSnapshot(remaining: 0, events: [], updatedAt: Date()) + } + defer { store._test_codexCreditsLoaderOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + controller.forgetClosedMenu(menu) + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `deferred OpenAI web refresh retries after active store refresh completes`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + store.isRefreshing = true + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + controller.deferOpenAIDashboardRefreshUntilMenuCloses(reason: "parent menu open") + controller.scheduleDeferredMenuInteractionRefreshIfNeeded() + + try? await Task.sleep(for: .milliseconds(50)) + #expect(await blocker.startedCount() == 0) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + store.isRefreshing = false + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `deferred OpenAI web refresh waits for deferred store refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + let providerBlocker = BlockingStatusMenuProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await providerBlocker.awaitRelease() + } + defer { store._test_providerRefreshOverride = nil } + let dashboardBlocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await dashboardBlocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + await providerBlocker.waitUntilStarted() + #expect(await dashboardBlocker.startedCount() == 0) + + await providerBlocker.resumeNext() + await dashboardBlocker.waitUntilStarted(count: 1) + #expect(await dashboardBlocker.startedCount() == 1) + + await dashboardBlocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `reopened menu keeps dashboard refresh deferred after store refresh`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = nil + store.lastOpenAIDashboardSnapshot = nil + let providerBlocker = BlockingStatusMenuProviderRefresh() + store._test_providerRefreshOverride = { provider in + guard provider == .codex else { return } + await providerBlocker.awaitRelease() + } + defer { store._test_providerRefreshOverride = nil } + let dashboardBlocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await dashboardBlocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + await providerBlocker.waitUntilStarted() + + let reopenedMenu = controller.makeMenu() + controller.menuWillOpen(reopenedMenu) + await providerBlocker.resumeNext() + try? await Task.sleep(for: .milliseconds(50)) + #expect(await dashboardBlocker.startedCount() == 0) + #expect(controller.deferredOpenAIDashboardRefreshReason != nil) + + controller.menuDidClose(reopenedMenu) + await dashboardBlocker.waitUntilStarted(count: 1) + #expect(await dashboardBlocker.startedCount() == 1) + + await dashboardBlocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [], + updatedAt: Date()))) + } + + @Test + func `codex parent menu close refreshes recent dashboard cache with no chart history`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = self.makeOpenAIDashboard(dailyBreakdown: [], updatedAt: Date()) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + StatusItemController.setDeferredMenuInteractionRefreshDelayForTesting(.zero) + defer { StatusItemController.resetDeferredMenuInteractionRefreshDelayForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + for _ in 0..<20 { + await Task.yield() + } + #expect(await blocker.startedCount() == 0) + + controller.menuDidClose(menu) + await blocker.waitUntilStarted(count: 1) + #expect(await blocker.startedCount() == 1) + + await blocker.resumeNext(with: .success(self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: Date()))) + } + + @Test + func `codex parent menu open throttles recent empty dashboard retry`() async { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.openAIWebBatterySaverEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let now = Date() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = self.makeOpenAIDashboard(dailyBreakdown: [], updatedAt: now.addingTimeInterval(-120)) + store.lastOpenAIDashboardSnapshot = store.openAIDashboard + store.lastOpenAIDashboardAttemptAt = now.addingTimeInterval(-60) + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + let blocker = BlockingManagedOpenAIDashboardLoader() + store._test_openAIDashboardLoaderOverride = { _, _, _, _ in + try await blocker.awaitResult() + } + defer { store._test_openAIDashboardLoaderOverride = nil } + + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + controller.menuRefreshEnabledOverrideForTesting = true + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + controller.menuDidClose(menu) + + try? await Task.sleep(for: .milliseconds(150)) + #expect(await blocker.startedCount() == 0) + } + + @Test + func `credits history arriving after open rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.showOptionalCreditsAndExtraUsage = true + self.enableOnlyCodex(settings) + + let now = Date() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: true) + store.credits = CreditsSnapshot(remaining: 100, events: [], updatedAt: now) + store.openAIDashboard = self.makeOpenAIDashboard(dailyBreakdown: [], updatedAt: now) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + #expect(self.menuItem(in: menu, id: "menuCardCredits") == nil) + + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: now.addingTimeInterval(10)) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + + let creditsItem = try #require(self.menuItem(in: menu, id: "menuCardCredits")) + #expect( + creditsItem.submenu?.items.first?.representedObject as? String == + StatusItemController.creditsHistoryChartID) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `fresh dashboard history with same day count rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.showOptionalCreditsAndExtraUsage = true + self.enableOnlyCodex(settings) + + let now = Date(timeIntervalSince1970: 100) + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: true) + store.credits = CreditsSnapshot(remaining: 100, events: [], updatedAt: now) + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: now) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + _ = try #require(self.menuItem(in: menu, id: "menuCardCredits")) + + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 99), + ], + updatedAt: now.addingTimeInterval(10)) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + + let creditsItem = try #require(self.menuItem(in: menu, id: "menuCardCredits")) + #expect(creditsItem.submenu?.items.first?.representedObject as? String == StatusItemController + .creditsHistoryChartID) + } + + @Test + func `token cost history arriving after open rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + #expect(self.menuItem(in: menu, id: "menuCardCost") == nil) + + store._setTokenSnapshotForTesting(self.makeCodexTokenCostSnapshot(), provider: .codex) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + + let costItem = try #require(self.menuItem(in: menu, id: "menuCardCost")) + #expect(costItem.submenu?.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + @Test + func `fresh token cost history with same day count rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.costUsageEnabled = true + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store._setTokenSnapshotForTesting( + self.makeCodexTokenCostSnapshot( + sessionTokens: 123, + sessionCostUSD: 0.12, + last30DaysTokens: 456, + last30DaysCostUSD: 1.23, + updatedAt: Date(timeIntervalSince1970: 100)), + provider: .codex) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + _ = try #require(self.menuItem(in: menu, id: "menuCardCost")) + + store._setTokenSnapshotForTesting( + self.makeCodexTokenCostSnapshot( + sessionTokens: 999, + sessionCostUSD: 0.99, + last30DaysTokens: 888, + last30DaysCostUSD: 8.88, + updatedAt: Date(timeIntervalSince1970: 200)), + provider: .codex) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + + let costItem = try #require(self.menuItem(in: menu, id: "menuCardCost")) + #expect(costItem.submenu?.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) + } + + @Test + func `plan utilization history arriving after open rebuilds parent menu after tracking ends`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + self.enableOnlyCodex(settings) + + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + let usageHistoryItem = try #require(self.menuItem(in: menu, id: "usageHistorySubmenu")) + #expect(usageHistoryItem.submenu?.items.first?.representedObject as? String == StatusItemController + .usageHistoryChartID) + let openedRevision = store.planUtilizationHistoryRevision + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: self.makeCodexPlanUtilizationSnapshot(), + now: Date()) + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(store.planUtilizationHistoryRevision > openedRevision) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + } + + @Test + func `dashboard attachment authorization arriving after open rebuilds parent menu after close`() async throws { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = false + settings.openAIWebAccessEnabled = true + settings.codexCookieSource = .auto + self.enableOnlyCodex(settings) + + let now = Date() + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + store.openAIDashboard = self.makeOpenAIDashboard( + dailyBreakdown: [ + OpenAIDashboardDailyBreakdown(day: "2026-05-24", services: [], totalCreditsUsed: 12), + ], + updatedAt: now) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + let key = ObjectIdentifier(menu) + controller.openMenus[key] = menu + controller.menuRefreshEnabledOverrideForTesting = true + + let openedVersion = try #require(controller.menuVersions[key]) + #expect(store.openAIDashboardAttachmentRevision == 0) + + store.openAIDashboardAttachmentAuthorized = true + + await self.waitUntilOpenMenuStaysStale(controller, key: key, after: openedVersion) + + #expect(store.openAIDashboardAttachmentRevision == 1) + #expect(controller.menuContentVersion != openedVersion) + #expect(controller.menuVersions[key] == openedVersion) + + await self.closeMenuAndWaitUntilFresh(controller, menu: menu, key: key) + } + + private func enableOnlyCodex(_ settings: SettingsStore) { + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + } + + private func menuItem(in menu: NSMenu, id: String) -> NSMenuItem? { + menu.items.first { ($0.representedObject as? String) == id } + } + + private func waitUntilMenuVersionChanges( + _ controller: StatusItemController, + from version: Int?) async + { + for _ in 0..<20 where controller.menuContentVersion == version { + await Task.yield() + } + } + + private func waitUntilOpenMenuStaysStale( + _ controller: StatusItemController, + key: ObjectIdentifier, + after version: Int?) async + { + for _ in 0..<40 { + guard controller.menuContentVersion != version else { + await Task.yield() + continue + } + guard controller.menuVersions[key] == version else { + await Task.yield() + continue + } + return + } + } + + private func closeMenuAndWaitUntilFresh( + _ controller: StatusItemController, + menu: NSMenu, + key: ObjectIdentifier) async + { + controller.menuDidClose(menu) + for _ in 0..<40 where controller.menuVersions[key] != controller.menuContentVersion { + await Task.yield() + } + if controller.menuVersions[key] != controller.menuContentVersion { + controller.menuWillOpen(menu) + } + for _ in 0..<40 where controller.menuVersions[key] != controller.menuContentVersion { + await Task.yield() + } + #expect(controller.menuVersions[key] == controller.menuContentVersion) + } + + private func makeOpenAIDashboard( + dailyBreakdown: [OpenAIDashboardDailyBreakdown], + updatedAt: Date) -> OpenAIDashboardSnapshot + { + OpenAIDashboardSnapshot( + signedInEmail: "codex@example.com", + codeReviewRemainingPercent: nil, + creditEvents: [], + dailyBreakdown: dailyBreakdown, + usageBreakdown: [], + creditsPurchaseURL: nil, + updatedAt: updatedAt) + } + + private func makeCodexTokenCostSnapshot( + sessionTokens: Int = 123, + sessionCostUSD: Double = 0.12, + last30DaysTokens: Int = 456, + last30DaysCostUSD: Double = 1.23, + updatedAt: Date = Date()) -> CostUsageTokenSnapshot + { + CostUsageTokenSnapshot( + sessionTokens: sessionTokens, + sessionCostUSD: sessionCostUSD, + last30DaysTokens: last30DaysTokens, + last30DaysCostUSD: last30DaysCostUSD, + daily: [ + CostUsageDailyReport.Entry( + date: "2026-05-24", + inputTokens: nil, + outputTokens: nil, + totalTokens: sessionTokens, + costUSD: last30DaysCostUSD, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: updatedAt) + } + + private func makeCodexPlanUtilizationSnapshot() -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow( + usedPercent: 35, + windowMinutes: 300, + resetsAt: Date().addingTimeInterval(1800), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 42, + windowMinutes: 10080, + resetsAt: Date().addingTimeInterval(86400), + resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "Plus Plan")) + } +} + +private actor BlockingStatusMenuProviderRefresh { + private var continuations: [CheckedContinuation] = [] + private var startWaiters: [CheckedContinuation] = [] + private var started = 0 + + func awaitRelease() async { + self.started += 1 + self.resumeStartWaiters() + await withCheckedContinuation { continuation in + self.continuations.append(continuation) + } + } + + func waitUntilStarted() async { + if self.started > 0 { return } + await withCheckedContinuation { continuation in + self.startWaiters.append(continuation) + } + } + + func resumeNext() { + guard !self.continuations.isEmpty else { return } + self.continuations.removeFirst().resume() + } + + private func resumeStartWaiters() { + let waiters = self.startWaiters + self.startWaiters = [] + for waiter in waiters { + waiter.resume() + } + } } diff --git a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift index 10ec3e24..6b90c164 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewSubmenuTests.swift @@ -58,7 +58,7 @@ extension StatusMenuTests { ($0.representedObject as? String) == "overviewRow-openai" }) #expect(openAIRow.submenu?.items.contains { - ($0.representedObject as? String) == StatusItemController.openAIAPIUsageChartID + ($0.representedObject as? String) == StatusItemController.costHistoryChartID } == true) } } diff --git a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift index 5207d21c..9fe93b78 100644 --- a/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift +++ b/Tests/CodexBarTests/StatusMenuSwitcherClickTests.swift @@ -24,6 +24,48 @@ struct StatusMenuSwitcherClickTests { syntheticTokenStore: NoopSyntheticTokenStore()) } + private func makeInstalledSwitcherShortcutMonitor() -> (controller: StatusItemController, menu: StatusItemMenu) { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = StatusItemMenu() + let switcher = ProviderSwitcherView( + providers: [.codex, .claude], + selected: .provider(.codex), + includesOverview: true, + width: 320, + showsIcons: false, + iconProvider: { _ in NSImage() }, + weeklyRemainingProvider: { _ in nil }, + onSelect: { _ in }) + let switcherItem = NSMenuItem() + switcherItem.view = switcher + menu.addItem(switcherItem) + menu.addItem(.separator()) + + controller.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) + return (controller, menu) + } + @Test func `merged switcher routes runtime clicks after overview round-trip`() throws { // Regression test for #867: after Provider → Overview, subsequent runtime clicks on a @@ -233,6 +275,103 @@ struct StatusMenuSwitcherClickTests { #expect(settings.selectedMenuProvider == .codex) } + @Test + func `merged switcher handles command number shortcuts in visible order`() async throws { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = false + StatusItemController.setMenuRefreshEnabledForTesting(false) + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + settings.mergedMenuLastSelectedWasOverview = false + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + let shouldEnable = provider == .codex || provider == .claude || provider == .cursor + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + let menu = try #require(controller.makeMenu() as? StatusItemMenu) + controller.menuWillOpen(menu) + #expect(menu.items.first?.view is ProviderSwitcherView) + + #expect(try controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("3", keyCode: 20), menu: menu)) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == false) + #expect(settings.selectedMenuProvider == .claude) + + #expect(try controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("1", keyCode: 18), menu: menu)) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == true) + #expect(settings.selectedMenuProvider == .claude) + + #expect(try !controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("9", keyCode: 25), menu: menu)) + await Task.yield() + #expect(settings.mergedMenuLastSelectedWasOverview == true) + #expect(settings.selectedMenuProvider == .claude) + } + + @Test + func `provider shortcut monitor is removed when tracked menu closes after switcher rebuild`() { + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let (controller, menu) = self.makeInstalledSwitcherShortcutMonitor() + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.providerSwitcherShortcutEventMonitor != nil) + #expect(controller.providerSwitcherShortcutMenuID == ObjectIdentifier(menu)) + + menu.removeAllItems() + controller.menuDidClose(menu) + + #expect(controller.providerSwitcherShortcutEventMonitor == nil) + #expect(controller.providerSwitcherShortcutMenuID == nil) + } + + @Test + func `switcher shortcut monitor is removed from direct close cleanup`() { + let previousMenuRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) + } + + let (controller, menu) = self.makeInstalledSwitcherShortcutMonitor() + defer { controller.releaseStatusItemsForTesting() } + + #expect(controller.providerSwitcherShortcutEventMonitor != nil) + #expect(controller.providerSwitcherShortcutMenuID == ObjectIdentifier(menu)) + + controller.forgetClosedMenu(menu) + + #expect(controller.providerSwitcherShortcutEventMonitor == nil) + #expect(controller.providerSwitcherShortcutMenuID == nil) + } + @Test func `switcher hover styling keeps layout stable`() { let view = ProviderSwitcherView( @@ -458,4 +597,59 @@ struct StatusMenuSwitcherClickTests { isARepeat: false, keyCode: keyCode)) } + + private static func commandKeyEvent(_ characters: String, keyCode: UInt16) throws -> NSEvent { + try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: 0, + windowNumber: 0, + context: nil, + characters: characters, + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode)) + } + + @Test + func `multi-row switcher uses compact height and stays inside bounds`() { + // 14 providers + Overview forces the four-row path and includes multi-word titles. + let view = ProviderSwitcherView( + providers: [ + .codex, + .claude, + .cursor, + .factory, + .zai, + .minimax, + .alibaba, + .opencodego, + .grok, + .groq, + .gemini, + .openrouter, + .perplexity, + .kiro, + ], + selected: .provider(.codex), + includesOverview: true, + width: 300, + showsIcons: true, + iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, + weeklyRemainingProvider: { _ in 50 }, + onSelect: { _ in }) + view.updateConstraintsForSubtreeIfNeeded() + view.layoutSubtreeIfNeeded() + + // All buttons must stay within switcher bounds (no vertical overflow). + for frame in view._test_buttonFrames() { + #expect(frame.minY >= 0) + #expect(frame.maxY <= view.bounds.maxY) + } + + #expect(view._test_rowCount() == 4) + #expect(view._test_rowHeight() == 44) + #expect(view.bounds.height == 188) + } } diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index e71d180a..935c72c1 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -521,7 +521,7 @@ struct StatusMenuTests { } @Test - func `open merged menu rebuilds switcher when usage bars mode changes`() { + func `open merged menu rebuilds switcher when usage bars mode changes`() async { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -562,6 +562,11 @@ struct StatusMenuTests { settings.usageBarsShowUsed = true controller.handleProviderConfigChange(reason: "usageBarsShowUsed") + for _ in 0..<20 + where initialSwitcherID == (menu.items.first?.view as? ProviderSwitcherView).map(ObjectIdentifier.init) + { + await Task.yield() + } let updatedSwitcher = menu.items.first?.view as? ProviderSwitcherView #expect(updatedSwitcher != nil) @@ -879,8 +884,8 @@ extension StatusMenuTests { statusBar: self.makeStatusBarForTesting()) let codexItem = try #require(controller.statusItems[.codex]) - #expect(!controller.statusItem.autosaveName.hasPrefix("codexbar-")) - #expect(!codexItem.autosaveName.hasPrefix("codexbar-")) + #expect(controller.statusItem.autosaveName == "codexbar-merged") + #expect(codexItem.autosaveName == "codexbar-codex") try settings.setProviderEnabled( provider: .gemini, @@ -889,8 +894,8 @@ extension StatusMenuTests { controller.handleProviderConfigChange(reason: "test") #expect(controller.statusItems[.codex] === codexItem) - #expect(controller.statusItems[.codex]?.autosaveName.hasPrefix("codexbar-") == false) - #expect(controller.statusItems[.gemini]?.autosaveName.hasPrefix("codexbar-") == false) + #expect(controller.statusItems[.codex]?.autosaveName == "codexbar-codex") + #expect(controller.statusItems[.gemini]?.autosaveName == "codexbar-gemini") } @Test @@ -1220,7 +1225,7 @@ extension StatusMenuTests { let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" } #expect(usageItem?.submenu?.items - .contains { ($0.representedObject as? String) == StatusItemController.openAIAPIUsageChartID } == true) + .contains { ($0.representedObject as? String) == StatusItemController.costHistoryChartID } == true) #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardHeader" } == false) #expect(menu.items.contains { ($0.representedObject as? String) == "menuCardExtraUsage" } == false) } diff --git a/Tests/CodexBarTests/StatusProbeTests.swift b/Tests/CodexBarTests/StatusProbeTests.swift index 8de79c18..dde1026a 100644 --- a/Tests/CodexBarTests/StatusProbeTests.swift +++ b/Tests/CodexBarTests/StatusProbeTests.swift @@ -520,6 +520,77 @@ struct StatusProbeTests { } } + @Test + func `surfaces claude subscription notice without quota data`() { + let sample = """ + You are currently using your subscription to power your Claude Code usage + """ + + do { + _ = try ClaudeStatusProbe.parse(text: sample) + #expect(Bool(false), "Parsing should fail for subscription notice without quota data") + } catch let ClaudeStatusProbeError.parseFailed(message) { + let lower = message.lowercased() + #expect(lower.contains("subscription")) + #expect(!lower.contains("still loading")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + + @Test + func `parse claude status subscription notice is distinct from loading stall`() { + let subscriptionOnly = "You are currently using your subscription to power your Claude Code usage" + let loadingOnly = """ + Settings: Status Config Usage (tab to cycle) + Loading usage data… + Esc to cancel + """ + + do { + _ = try ClaudeStatusProbe.parse(text: subscriptionOnly) + #expect(Bool(false), "Subscription notice should fail parsing") + } catch let ClaudeStatusProbeError.parseFailed(subMessage) { + #expect(!subMessage.lowercased().contains("still loading")) + } catch { + #expect(Bool(false), "Unexpected error for subscription: \(error)") + } + + do { + _ = try ClaudeStatusProbe.parse(text: loadingOnly) + #expect(Bool(false), "Loading panel should fail parsing") + } catch let ClaudeStatusProbeError.parseFailed(loadMessage) { + #expect(loadMessage.lowercased().contains("loading")) + } catch { + #expect(Bool(false), "Unexpected error for loading: \(error)") + } + } + + @Test + func `parse claude status mixed loading and subscription notice surfaces subscription error`() { + // PTY capture containing both an intermediate "Loading usage data…" panel and the final + // Claude CLI 2.1.148 subscription notice. The subscription error must be surfaced, not + // the still-loading stall, so the UI shows the precise subscription message. + let mixedCapture = """ + Settings: Status Config Usage (tab to cycle) + Loading usage data… + Esc to cancel + + You are currently using your subscription to power your Claude Code usage + """ + + do { + _ = try ClaudeStatusProbe.parse(text: mixedCapture) + #expect(Bool(false), "Parsing should fail for mixed loading+subscription capture") + } catch let ClaudeStatusProbeError.parseFailed(message) { + let lower = message.lowercased() + #expect(lower.contains("subscription")) + #expect(!lower.contains("still loading")) + } catch { + #expect(Bool(false), "Unexpected error for mixed capture: \(error)") + } + } + @Test func `parses claude reset time only`() throws { let now = Date(timeIntervalSince1970: 1_733_690_000) diff --git a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift index ce06f27f..2c1ee850 100644 --- a/Tests/CodexBarTests/StepFunUsageFetcherTests.swift +++ b/Tests/CodexBarTests/StepFunUsageFetcherTests.swift @@ -1,6 +1,6 @@ -import CodexBarCore import Foundation import Testing +@testable import CodexBarCore struct StepFunSettingsReaderTests { @Test @@ -231,3 +231,569 @@ struct StepFunTokenNormalizerTests { #expect(StepFunTokenNormalizer.normalize(" token123 ") == "token123") } } + +@Suite(.serialized) +struct StepFunTokenRefreshTests { + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + @Test + func `refresh token returns combined token pair`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + #expect(request.url?.path.contains("RefreshToken") == true) + #expect(request.value(forHTTPHeaderField: "Oasis-Token") == "old-access...old-refresh") + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("old-access...old-refresh") == true) + recorder.recordRefreshCall() + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + let token = try await StepFunUsageFetcher.refreshToken(token: "old-access...old-refresh") + #expect(token == "new-access...new-refresh") + #expect(recorder.refreshCallCount == 1) + } + } + + @Test + func `manual token auth failure refreshes token account and retries usage`() async throws { + let accountID = UUID() + let updateRecorder = StepFunTokenUpdateRecorder() + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("old-access...old-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("new-access...new-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + #expect(request.value(forHTTPHeaderField: "Oasis-Token") == "old-access...old-refresh") + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext( + settings: settings, + selectedTokenAccountID: accountID, + tokenUpdater: { provider, updatedAccountID, token in + #expect(provider == .stepfun) + #expect(updatedAccountID == accountID) + await updateRecorder.record(token) + }) + + let result = try await StepFunWebFetchStrategy().fetch(context) + + #expect(result.usage.identity?.loginMethod == "Plus") + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + let updatedToken = await updateRecorder.recordedToken() + #expect(updatedToken == "new-access...new-refresh") + } + } + + @Test + func `manual token auth failure refreshes settings token and retries usage`() async throws { + let updateRecorder = StepFunTokenUpdateRecorder() + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("new-access...new-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext( + settings: settings, + manualTokenUpdater: { provider, token in + #expect(provider == .stepfun) + await updateRecorder.record(token) + }) + + _ = try await StepFunWebFetchStrategy().fetch(context) + + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + let updatedToken = await updateRecorder.recordedToken() + #expect(updatedToken == "new-access...new-refresh") + } + } + + @Test + func `stale cached token falls back to configured env token`() async throws { + CookieHeaderCache.store(provider: .stepfun, cookieHeader: "stale-access...stale-refresh", sourceLabel: "test") + defer { CookieHeaderCache.clear(provider: .stepfun) } + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("stale-access...stale-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("env-access...env-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"expired"}"#) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings(cookieSource: .auto)) + let context = self.makeContext( + settings: settings, + env: ["STEPFUN_TOKEN": "env-access...env-refresh"]) + + _ = try await StepFunWebFetchStrategy().fetch(context) + + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + #expect(CookieHeaderCache.load(provider: .stepfun) == nil) + } + } + + @Test + func `stale cached and env tokens fall back to env login credentials`() async throws { + CookieHeaderCache.store(provider: .stepfun, cookieHeader: "stale-access...stale-refresh", sourceLabel: "test") + defer { CookieHeaderCache.clear(provider: .stepfun) } + + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.isEmpty || path == "/" { + return Self.jsonResponse( + for: request, + body: "{}", + headers: ["Set-Cookie": "INGRESSCOOKIE=ingress-cookie; Path=/"]) + } + + if path.contains("RegisterDevice") { + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "anon-access"}, + "refreshToken": {"raw": "anon-refresh"} + } + """) + } + + if path.contains("SignInByPassword") { + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "login-access"}, + "refreshToken": {"raw": "login-refresh"} + } + """) + } + + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("stale-access...stale-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + if call == 2 { + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("env-access...env-refresh") == true) + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + #expect(request.value(forHTTPHeaderField: "Cookie")? + .contains("login-access...login-refresh") == true) + return Self.usageResponse(for: request) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"expired"}"#) + } + + if path.contains("GetStepPlanStatus") { + return Self.jsonResponse( + for: request, + body: #"{"status":1,"subscription":{"name":"Plus","plan_type":1,"status":1}}"#) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings(cookieSource: .auto)) + let context = self.makeContext( + settings: settings, + env: [ + "STEPFUN_TOKEN": "env-access...env-refresh", + "STEPFUN_USERNAME": "user@example.com", + "STEPFUN_PASSWORD": "password", + ]) + + _ = try await StepFunWebFetchStrategy().fetch(context) + + #expect(recorder.usageCallCount == 3) + #expect(recorder.refreshCallCount == 1) + #expect(CookieHeaderCache.load(provider: .stepfun)?.cookieHeader == "login-access...login-refresh") + } + } + + @Test + func `post refresh non auth usage failure is not rewritten as auth guidance`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + let call = recorder.recordUsageCall() + if call == 1 { + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + return Self.jsonResponse(for: request, statusCode: 500, body: #"{"error":"temporary"}"#) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse( + for: request, + body: """ + { + "accessToken": {"raw": "new-access"}, + "refreshToken": {"raw": "new-refresh"} + } + """) + } + + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext(settings: settings) + + do { + _ = try await StepFunWebFetchStrategy().fetch(context) + Issue.record("Expected post-refresh usage failure") + } catch let StepFunUsageError.apiError(message) { + #expect(message == "HTTP 500") + } catch { + Issue.record("Expected StepFunUsageError.apiError, got \(error)") + } + + #expect(recorder.usageCallCount == 2) + #expect(recorder.refreshCallCount == 1) + } + } + + @Test + func `manual token refresh failure does not fall back to ambient env credentials`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + _ = recorder.recordUsageCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"unauthorized"}"#) + } + + if path.contains("RefreshToken") { + recorder.recordRefreshCall() + return Self.jsonResponse(for: request, statusCode: 401, body: #"{"error":"expired"}"#) + } + + Issue.record("Manual token recovery should not call login endpoint: \(path)") + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext( + settings: settings, + env: [ + "STEPFUN_USERNAME": "someone@example.com", + "STEPFUN_PASSWORD": "secret", + ]) + + do { + _ = try await StepFunWebFetchStrategy().fetch(context) + Issue.record("Expected manual token auth failure") + } catch let StepFunUsageError.apiError(message) { + #expect(message.contains("Refresh the Oasis-Token")) + } catch { + Issue.record("Expected StepFunUsageError.apiError, got \(error)") + } + + #expect(recorder.usageCallCount == 1) + #expect(recorder.refreshCallCount == 1) + } + } + + @Test + func `non auth token wording does not trigger refresh recovery`() async throws { + try await self.withStubProtocol { recorder in + StepFunStubURLProtocol.handler = { request in + let path = request.url?.path ?? "" + if path.contains("QueryStepPlanRateLimit") { + _ = recorder.recordUsageCall() + return Self.jsonResponse( + for: request, + body: #"{"status":0,"message":"token plan status temporarily unavailable"}"#) + } + + Issue.record("Non-auth usage error should not call recovery endpoint: \(path)") + return Self.jsonResponse(for: request, statusCode: 404, body: #"{"error":"unexpected"}"#) + } + + let settings = ProviderSettingsSnapshot.make( + stepfun: ProviderSettingsSnapshot.StepFunProviderSettings( + cookieSource: .manual, + manualToken: "old-access...old-refresh")) + let context = self.makeContext(settings: settings) + + do { + _ = try await StepFunWebFetchStrategy().fetch(context) + Issue.record("Expected provider API error") + } catch let StepFunUsageError.apiError(message) { + #expect(message == "token plan status temporarily unavailable") + } catch { + Issue.record("Expected StepFunUsageError.apiError, got \(error)") + } + + #expect(recorder.usageCallCount == 1) + #expect(recorder.refreshCallCount == 0) + } + } + + private func makeContext( + settings: ProviderSettingsSnapshot?, + env: [String: String] = [:], + selectedTokenAccountID: UUID? = nil, + tokenUpdater: ProviderFetchContext.TokenAccountTokenUpdater? = nil, + manualTokenUpdater: ProviderFetchContext.ProviderManualTokenUpdater? = nil) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: env, + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + selectedTokenAccountID: selectedTokenAccountID, + tokenAccountTokenUpdater: tokenUpdater, + providerManualTokenUpdater: manualTokenUpdater) + } + + private func withStubProtocol( + _ body: (StepFunRequestRecorder) async throws -> Void) async throws + { + let recorder = StepFunRequestRecorder() + let registered = URLProtocol.registerClass(StepFunStubURLProtocol.self) + defer { + if registered { + URLProtocol.unregisterClass(StepFunStubURLProtocol.self) + } + StepFunStubURLProtocol.handler = nil + } + try await body(recorder) + } + + private static func usageResponse(for request: URLRequest) -> (HTTPURLResponse, Data) { + self.jsonResponse( + for: request, + body: """ + { + "status": 1, + "five_hour_usage_left_rate": 0.8, + "weekly_usage_left_rate": 0.6, + "five_hour_usage_reset_time": "1777528800", + "weekly_usage_reset_time": "1777899600" + } + """) + } + + private static func jsonResponse( + for request: URLRequest, + statusCode: Int = 200, + body: String, + headers: [String: String] = ["Content-Type": "application/json"]) -> (HTTPURLResponse, Data) + { + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: headers)! + return (response, Data(body.utf8)) + } +} + +private actor StepFunTokenUpdateRecorder { + private var token: String? + + func record(_ token: String) { + self.token = token + } + + func recordedToken() -> String? { + self.token + } +} + +private final class StepFunRequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var usageCalls = 0 + private var refreshCalls = 0 + + var usageCallCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.usageCalls + } + + var refreshCallCount: Int { + self.lock.lock() + defer { self.lock.unlock() } + return self.refreshCalls + } + + func recordUsageCall() -> Int { + self.lock.lock() + defer { self.lock.unlock() } + self.usageCalls += 1 + return self.usageCalls + } + + func recordRefreshCall() { + self.lock.lock() + defer { self.lock.unlock() } + self.refreshCalls += 1 + } +} + +private final class StepFunStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + request.url?.host == "platform.stepfun.com" + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = Self.handler else { + self.client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(self.request) + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + self.client?.urlProtocolDidFinishLoading(self) + } catch { + self.client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/Tests/CodexBarTests/TTYIntegrationTests.swift b/Tests/CodexBarTests/TTYIntegrationTests.swift index b11eb181..a17e3c3f 100644 --- a/Tests/CodexBarTests/TTYIntegrationTests.swift +++ b/Tests/CodexBarTests/TTYIntegrationTests.swift @@ -7,6 +7,9 @@ import Testing struct TTYIntegrationTests { @Test func `codex RPC usage live`() async throws { + guard ProcessInfo.processInfo.environment["LIVE_CODEX_TTY"] == "1" else { + return + } let fetcher = UsageFetcher() do { let snapshot = try await fetcher.loadLatestUsage() @@ -62,13 +65,30 @@ struct TTYIntegrationTests { defer { Task { await ClaudeCLISession.shared.reset() } } let snapshot = try await ClaudeCLISession.withIsolatedSessionForTesting { - try await ClaudeStatusProbe(claudeBinary: cli.path, timeout: 8).fetch() + try await ClaudeStatusProbe(claudeBinary: cli.path, timeout: 10).fetch() } #expect(snapshot.sessionPercentLeft == 93) #expect(snapshot.weeklyPercentLeft == 79) } + @Test + func `claude pty usage stops on subscription notice`() async throws { + let cli = try Self.makeSubscriptionNoticeClaudeCLI() + defer { Task { await ClaudeCLISession.shared.reset() } } + + do { + try await ClaudeCLISession.withIsolatedSessionForTesting { + _ = try await ClaudeStatusProbe(claudeBinary: cli.path, timeout: 3).fetch() + } + #expect(Bool(false), "Subscription notice should fail parsing") + } catch let ClaudeStatusProbeError.parseFailed(message) { + #expect(message.lowercased().contains("subscription")) + } catch { + #expect(Bool(false), "Unexpected error: \(error)") + } + } + private static func makeSlowUsageClaudeCLI() throws -> URL { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString)", isDirectory: true) @@ -81,7 +101,7 @@ struct TTYIntegrationTests { *"/usage"*) printf '%s\\n' 'Settings Status Config Usage' printf '%s\\n' 'Current session' - sleep 4 + sleep 2 printf '%s\\n' '93% left' printf '%s\\n' 'Current week (all models)' printf '%s\\n' '79% left' @@ -96,4 +116,27 @@ struct TTYIntegrationTests { try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) return url } + + private static func makeSubscriptionNoticeClaudeCLI() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("CodexBarTTYTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent("claude") + let script = """ + #!/bin/sh + while IFS= read -r line; do + case "$line" in + *"/usage"*) + printf '%s\\n' 'You are currently using your subscription to power your Claude Code usage' + ;; + *"/status"*) + printf '%s\\n' 'Account: subscription@example.com' + ;; + esac + done + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + return url + } } diff --git a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift index a7cb75e1..14dfe639 100644 --- a/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift +++ b/Tests/CodexBarTests/TokenAccountEnvironmentPrecedenceTests.swift @@ -107,6 +107,51 @@ struct TokenAccountEnvironmentPrecedenceTests { #expect(ollamaSettings.manualCookieHeader == "session=account-token") } + @Test + func `stepfun CLI snapshot reads manual token from region field`() throws { + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .stepfun, + region: "Oasis-Token=manual-token; Oasis-Webid=web"), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .stepfun, account: nil)) + let stepfunSettings = try #require(snapshot.stepfun) + + #expect(stepfunSettings.cookieSource == .manual) + #expect(stepfunSettings.manualToken == "Oasis-Token=manual-token; Oasis-Webid=web") + } + + @Test + func `stepfun CLI token account overrides region manual token`() throws { + let account = ProviderTokenAccount( + id: UUID(), + label: "StepFun", + token: "account-token", + addedAt: Date().timeIntervalSince1970, + lastUsed: nil) + let config = CodexBarConfig( + providers: [ + ProviderConfig( + id: .stepfun, + region: "manual-token", + tokenAccounts: ProviderTokenAccountData( + version: 1, + accounts: [account], + activeIndex: 0)), + ]) + let selection = TokenAccountCLISelection(label: nil, index: nil, allAccounts: false) + let tokenContext = try TokenAccountCLIContext(selection: selection, config: config, verbose: false) + let resolvedAccount = try #require(tokenContext.resolvedAccounts(for: .stepfun).first) + let snapshot = try #require(tokenContext.settingsSnapshot(for: .stepfun, account: resolvedAccount)) + let stepfunSettings = try #require(snapshot.stepfun) + + #expect(stepfunSettings.cookieSource == .manual) + #expect(stepfunSettings.manualToken == "account-token") + } + @Test func `claude OAuth token account overrides environment in app environment builder`() { let settings = Self.makeSettingsStore(suite: "TokenAccountEnvironmentPrecedenceTests-claude-app") diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 15cd6899..873591d4 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -3,29 +3,116 @@ import Foundation import Testing @testable import CodexBar +@Suite(.serialized) struct UsageFormatterTests { + private static let usageFormatterLocalizationKeys: [String] = [ + "%@ left", + "Resets %@", + "Resets in %@", + "Resets now", + "Updated %@", + "Updated %@h ago", + "Updated %@m ago", + "Updated just now", + "usage_percent_suffix_left", + "usage_percent_suffix_used", + ] + @Test func `formats usage line`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() let line = UsageFormatter.usageLine(remaining: 25, used: 75, showUsed: false) #expect(line == "25% left") } @Test func `formats usage line show used`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() let line = UsageFormatter.usageLine(remaining: 25, used: 75, showUsed: true) #expect(line == "75% used") } + @Test + func `usage line respects injected localization provider`() { + UsageFormatter.setLocalizationProvider { key in + switch key { + case "usage_percent_suffix_left": "剩余" + case "usage_percent_suffix_used": "已使用" + default: key + } + } + defer { UsageFormatter.clearLocalizationProvider() } + + #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: false) == "22% 剩余") + #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: true) == "78% 已使用") + } + + @Test + func `default locale fallback matches stable en US POSIX behavior`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + + let now = Date(timeIntervalSince1970: 1_710_048_000) + let old = now.addingTimeInterval(-(26 * 3600)) + + let defaultOutput = UsageFormatter.updatedString(from: old, now: now) + UsageFormatter.setLocaleProvider { Locale(identifier: "en_US_POSIX") } + let injectedStableOutput = UsageFormatter.updatedString(from: old, now: now) + UsageFormatter.clearLocaleProvider() + + #expect(defaultOutput == injectedStableOutput) + } + + @Test + func `injected zh Hans locale applies app language formatting`() { + UsageFormatter.setLocalizationProvider { key in + switch key { + case "Updated %@": + "更新于 %@" + default: + key + } + } + UsageFormatter.setLocaleProvider { Locale(identifier: "zh-Hans") } + defer { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + } + + let now = Date(timeIntervalSince1970: 1_710_048_000) + let old = now.addingTimeInterval(-(26 * 3600)) + let output = UsageFormatter.updatedString(from: old, now: now) + + #expect(output.hasPrefix("更新于 ")) + } + + @Test + func `clearing locale provider returns to stable default behavior`() { + UsageFormatter.clearLocalizationProvider() + UsageFormatter.clearLocaleProvider() + + let now = Date(timeIntervalSince1970: 1_710_048_000) + let old = now.addingTimeInterval(-(26 * 3600)) + let baseline = UsageFormatter.updatedString(from: old, now: now) + + UsageFormatter.setLocaleProvider { Locale(identifier: "fr_FR") } + _ = UsageFormatter.updatedString(from: old, now: now) + UsageFormatter.clearLocaleProvider() + + let restored = UsageFormatter.updatedString(from: old, now: now) + #expect(restored == baseline) + } + @Test func `relative updated recent`() { let now = Date() let fiveHoursAgo = now.addingTimeInterval(-5 * 3600) let text = UsageFormatter.updatedString(from: fiveHoursAgo, now: now) - #expect(text.hasPrefix("Updated ")) - // Output must stay in English regardless of the host system locale, - // matching the surrounding hardcoded English UI labels. + #expect(text.hasPrefix("Updated ") || text.hasPrefix("更新")) #expect(text.contains("5")) - #expect(text.lowercased().contains("ago")) + #expect(text.lowercased().contains("ago") || text.contains("前")) } @Test @@ -234,4 +321,46 @@ struct UsageFormatterTests { #expect(UsageFormatter.byteCountString(5 * 1024 * 1024) == "5 MB") #expect(UsageFormatter.byteCountString(Int64(1536 * 1024 * 1024)) == "1.5 GB") } + + @Test + func `usage formatter localization keys exist in en and zh Hans with matching placeholders`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + let enURL = root.appendingPathComponent("Sources/CodexBar/Resources/en.lproj/Localizable.strings") + let zhURL = root.appendingPathComponent("Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings") + + let en = try Self.readStringsTable(at: enURL) + let zh = try Self.readStringsTable(at: zhURL) + + for key in Self.usageFormatterLocalizationKeys { + let enValue = try #require(en[key], "Missing en key: \(key)") + let zhValue = try #require(zh[key], "Missing zh-Hans key: \(key)") + #expect( + Self.placeholderTokens(in: enValue) == Self.placeholderTokens(in: zhValue), + "Placeholder mismatch for key '\(key)': en='\(enValue)' zh='\(zhValue)'") + } + } + + private static func readStringsTable(at url: URL) throws -> [String: String] { + guard let dict = NSDictionary(contentsOf: url) as? [String: String] else { + throw NSError( + domain: "UsageFormatterTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse strings file at \(url.path)"]) + } + return dict + } + + private static func placeholderTokens(in value: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: "%(?:\\d+\\$)?[@dDuUxXfFeEgGcCsSpaA]") else { + return [] + } + let nsRange = NSRange(value.startIndex.. [String: String] { + guard let dict = NSDictionary(contentsOf: url) as? [String: String] else { + throw NSError( + domain: "UsagePaceTextTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to parse strings file at \(url.path)"]) + } + return dict + } + + private static func placeholderTokens(in value: String) -> [String] { + guard let regex = try? NSRegularExpression(pattern: "%(?:\\d+\\$)?[@dDuUxXfFeEgGcCsSpaA]") else { + return [] + } + let nsRange = NSRange(value.startIndex.. SettingsStore { + let suite = "UsageStoreCachedTokenHydrationTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.costUsageHistoryDays = historyDays + settings.openAIWebAccessEnabled = false + settings.codexCookieSource = .off + settings.providerDetectionCompleted = true + + let registry = ProviderRegistry.shared + for provider in UsageProvider.allCases { + guard let metadata = registry.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex) + } + return settings + } + + private static func writeCodexSessionFile( + homeRoot: URL, + env: CostUsageTestEnvironment, + day: Date, + filename: String, + tokens: Int) throws + { + let comps = Calendar.current.dateComponents([.year, .month, .day], from: day) + let dir = homeRoot + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(String(format: "%04d", comps.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", comps.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let model = "openai/gpt-5.4" + let url = dir.appendingPathComponent(filename, isDirectory: false) + try env.jsonl([ + [ + "type": "turn_context", + "timestamp": env.isoString(for: day), + "payload": ["model": model], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": model, + ], + ], + ], + ]).write(to: url, atomically: true, encoding: .utf8) + } +} diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index 5b8c641d..d17aa924 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -352,6 +352,44 @@ struct UsageStoreCoverageTests { #expect(store.enabledProvidersForBackgroundWork().isEmpty) } + @Test + func `widget snapshot projects provider derived token usage`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-widget-provider-cost") + let store = Self.makeUsageStore(settings: settings) + let day = MistralDailyUsageBucket( + day: "2026-05-26", + cost: 1.2, + inputTokens: 10, + cachedTokens: 0, + outputTokens: 5, + models: []) + store._setSnapshotForTesting(MistralUsageSnapshot( + totalCost: 9, + currency: "eur", + currencySymbol: "€", + totalInputTokens: 10, + totalOutputTokens: 5, + totalCachedTokens: 0, + modelCount: 1, + daily: [day], + startDate: nil, + endDate: nil, + updatedAt: Date()).toUsageSnapshot(), provider: .mistral) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "provider-cost") + await store.widgetSnapshotPersistTask?.value + + let mistralEntry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .mistral }) + #expect(mistralEntry.tokenUsage?.currencyCode == "EUR") + #expect(mistralEntry.tokenUsage?.sessionLabel == "Latest billing day") + #expect(mistralEntry.tokenUsage?.last30DaysLabel == "This month") + #expect(mistralEntry.tokenUsage?.last30DaysCostUSD == 9) + } + @Test func `unavailable provider with only cached status gets single cleanup pass`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-background-status-cleanup") @@ -388,7 +426,9 @@ struct UsageStoreCoverageTests { func `status indicators and failure gate`() { #expect(!ProviderStatusIndicator.none.hasIssue) #expect(ProviderStatusIndicator.maintenance.hasIssue) - #expect(ProviderStatusIndicator.unknown.label == "Status unknown") + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(ProviderStatusIndicator.unknown.label == "Status unknown") + } var gate = ConsecutiveFailureGate() let first = gate.shouldSurfaceError(onFailureWithPriorData: true) @@ -431,6 +471,90 @@ struct UsageStoreCoverageTests { NSError(domain: NSCocoaErrorDomain, code: 0))) } + @Test + func `startup status network failure schedules bounded retry`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-startup-status-retry") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + store._test_providerStatusFetchOverride = { _ in + throw URLError(.notConnectedToInternet) + } + defer { store._test_providerStatusFetchOverride = nil } + + var scheduled: [(attempt: Int, delay: TimeInterval)] = [] + store._test_startupConnectivityRetryScheduled = { attempt, delay in + scheduled.append((attempt, delay)) + } + defer { store._test_startupConnectivityRetryScheduled = nil } + + await store.refresh() + defer { + store.startupConnectivityRetryTask?.cancel() + store.startupConnectivityRetryTask = nil + } + + #expect(scheduled.map(\.attempt) == [1]) + #expect(scheduled.map(\.delay) == [15]) + #expect(store.statuses[.codex]?.indicator == .unknown) + #expect(store.statuses[.codex]?.description?.isEmpty == false) + } + + @Test + func `startup connectivity retry refreshes status and clears retry task after recovery`() async throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-startup-status-recovery") + settings.refreshFrequency = .manual + settings.statusChecksEnabled = true + try Self.enableOnly(.codex, settings: settings) + + let store = Self.makeUsageStore(settings: settings) + store._test_providerRefreshOverride = { _ in } + defer { store._test_providerRefreshOverride = nil } + + var statusAttempts = 0 + store._test_providerStatusFetchOverride = { _ in + statusAttempts += 1 + if statusAttempts == 1 { + throw URLError(.cannotFindHost) + } + return ProviderStatus(indicator: .none, description: "Operational", updatedAt: Date()) + } + defer { store._test_providerStatusFetchOverride = nil } + + let sleepGate = StartupConnectivityRetrySleepGate() + store._test_startupConnectivityRetrySleepOverride = { delay in + try await sleepGate.sleep(delay) + } + defer { store._test_startupConnectivityRetrySleepOverride = nil } + + await store.refresh() + await sleepGate.waitUntilSleeping() + let retryTask = try #require(store.startupConnectivityRetryTask) + + await sleepGate.resume() + await retryTask.value + + #expect(statusAttempts == 2) + #expect(store.statuses[.codex]?.indicator == ProviderStatusIndicator.none) + #expect(store.statuses[.codex]?.description == "Operational") + #expect(store.startupConnectivityRetryTask == nil) + } + + @Test + func `startup connectivity retry classification is bounded and excludes cancellation`() { + #expect(UsageStore.startupConnectivityRetryDelay(forAttempt: 1) == 15) + #expect(UsageStore.startupConnectivityRetryDelay(forAttempt: 4) == 300) + #expect(UsageStore.startupConnectivityRetryDelay(forAttempt: 5) == nil) + #expect(UsageStore.isStartupConnectivityRetryableError(URLError(.timedOut))) + #expect(UsageStore.isStartupConnectivityRetryableError(URLError(.notConnectedToInternet))) + #expect(!UsageStore.isStartupConnectivityRetryableError(URLError(.cancelled))) + #expect(!UsageStore.isStartupConnectivityRetryableError(CancellationError())) + } + private static func makeSettingsStore( suite: String, zaiTokenStore: any ZaiTokenStoring = NoopZaiTokenStore(), @@ -470,6 +594,49 @@ struct UsageStoreCoverageTests { settings: settings, environmentBase: [:]) } + + private static func enableOnly(_ enabledProvider: UsageProvider, settings: SettingsStore) throws { + let metadata = ProviderRegistry.shared.metadata + for provider in UsageProvider.allCases { + try settings.setProviderEnabled( + provider: provider, + metadata: #require(metadata[provider]), + enabled: provider == enabledProvider) + } + } +} + +private actor StartupConnectivityRetrySleepGate { + private var continuation: CheckedContinuation? + private var waiters: [CheckedContinuation] = [] + + func sleep(_ delay: TimeInterval) async throws { + #expect(delay == 15) + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + self.resumeWaiters() + } + } + + func waitUntilSleeping() async { + if self.continuation != nil { return } + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } + + func resume() { + self.continuation?.resume() + self.continuation = nil + } + + private func resumeWaiters() { + let waiters = self.waiters + self.waiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } } private final class InMemoryZaiTokenStore: ZaiTokenStoring, @unchecked Sendable { diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 53070c75..aa3207f6 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -424,6 +424,16 @@ struct UsageStorePlanUtilizationTests { @MainActor @Test func `detail line uses lowercase am pm for session hover`() { + let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage") + UserDefaults.standard.set("en", forKey: "appLanguage") + defer { + if let previousLanguage { + UserDefaults.standard.set(previousLanguage, forKey: "appLanguage") + } else { + UserDefaults.standard.removeObject(forKey: "appLanguage") + } + } + let boundary = Date(timeIntervalSince1970: 1_710_048_000) // Mar 11, 2024 1:20 pm UTC let histories = [ planSeries(name: .session, windowMinutes: 300, entries: [ @@ -445,6 +455,16 @@ struct UsageStorePlanUtilizationTests { @MainActor @Test func `detail line uses lowercase am pm for weekly hover`() { + let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage") + UserDefaults.standard.set("en", forKey: "appLanguage") + defer { + if let previousLanguage { + UserDefaults.standard.set(previousLanguage, forKey: "appLanguage") + } else { + UserDefaults.standard.removeObject(forKey: "appLanguage") + } + } + let boundary = Date(timeIntervalSince1970: 1_710_048_000) // Mar 11, 2024 1:20 pm UTC let histories = [ planSeries(name: .weekly, windowMinutes: 10080, entries: [ diff --git a/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift new file mode 100644 index 00000000..dcc79780 --- /dev/null +++ b/Tests/CodexBarTests/UsageStoreWidgetSnapshotTests.swift @@ -0,0 +1,50 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct UsageStoreWidgetSnapshotTests { + @Test + func `widget snapshot includes antigravity tertiary usage row`() async throws { + let suite = "UsageStoreWidgetSnapshotTests-antigravity-tertiary" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 30, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + var widgetSnapshots: [WidgetSnapshot] = [] + store._test_widgetSnapshotSaveOverride = { widgetSnapshots.append($0) } + defer { store._test_widgetSnapshotSaveOverride = nil } + + store.persistWidgetSnapshot(reason: "antigravity-tertiary-test") + await store.widgetSnapshotPersistTask?.value + + let entry = try #require(widgetSnapshots.last?.entries.first { $0.provider == .antigravity }) + #expect(entry.usageRows?.map(\.id) == ["primary", "secondary", "tertiary"]) + #expect(entry.usageRows?.map(\.title) == ["Claude", "Gemini Pro", "Gemini Flash"]) + #expect(entry.usageRows?.compactMap(\.percentLeft) == [90, 80, 70]) + } +} diff --git a/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift new file mode 100644 index 00000000..b6545f07 --- /dev/null +++ b/Tests/CodexBarTests/UserFacingLocalizationCoverageTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing + +struct UserFacingLocalizationCoverageTests { + @Test + func `selected user-facing UI surfaces avoid raw English literals`() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + + let forbiddenMarkersByFile: [String: [String]] = [ + "Sources/CodexBar/CostHistoryChartMenuView.swift": [ + ".value(\"Day\"", + ".value(\"Cost\"", + ".value(\"Cap start\"", + ".value(\"Cap end\"", + ], + "Sources/CodexBar/CreditsHistoryChartMenuView.swift": [ + ".value(\"Day\"", + ".value(\"Credits used\"", + ".value(\"Cap start\"", + ".value(\"Cap end\"", + "Text(\"Total (30d):", + "\\(total) credits", + "\\(used) credits", + ], + "Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift": [ + ".value(\"Series\"", + ".value(\"Capacity Start\"", + ".value(\"Capacity End\"", + ".value(\"Utilization Start\"", + ".value(\"Utilization End\"", + ], + "Sources/CodexBar/PreferencesCodexAccountsSection.swift": [ + "?? \"No system account\"", + "return \"Adding Account…\"", + "return \"Add Account\"", + "return \"Re-authenticating…\"", + "return \"Re-auth\"", + "ProviderSettingsSection(title: \"Accounts\")", + "Text(\"Active\")", + "Text(\"Choose which Codex account CodexBar should follow.\")", + "Text(\"Account\")", + "Text(\"No Codex accounts detected yet.\")", + "Text(\"System\")", + "Text(\"The default Codex account on this Mac.\")", + "Text(\"(System)\")", + "Button(\"Remove\")", + ], + "Sources/CodexBar/PreferencesProviderDetailView.swift": [ + ".help(\"Refresh\")", + "accessibilityLabel: \"Usage used\"", + ], + "Sources/CodexBar/PreferencesProviderErrorView.swift": [ + ".help(\"Copy error\")", + ], + "Sources/CodexBar/PreferencesProviderSettingsRows.swift": [ + "Text(self.title)", + "Text(self.toggle.title)", + "Text(self.toggle.subtitle)", + "Button(action.title)", + "Text(self.picker.title)", + "Text(option.title)", + "Text(trimmedTitle)", + "Text(trimmedSubtitle)", + "Text(self.descriptor.title)", + "Text(self.descriptor.subtitle)", + "Text(\"No token accounts yet.\")", + "Button(\"Remove\")", + "TextField(\"Label\"", + "Button(\"Add\")", + "TextField(\"Org ID (optional)\"", + ".help(\"Optional organization ID for accounts linked to multiple Anthropic organizations.\")", + "Button(\"Open token file\")", + "Button(\"Reload\")", + "Text(\"No organizations loaded. Click Refresh after setting your API key.\")", + "Button(\"Refresh organizations\")", + ], + "Sources/CodexBar/PreferencesProviderSidebarView.swift": [ + ".help(\"Drag to reorder\")", + "\"Disabled —", + ".accessibilityLabel(\"Reorder\")", + ], + "Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift": [ + "Text(\"Subscription Utilization\")", + ], + "Sources/CodexBar/StatusItemController+CostMenuCard.swift": [ + "static let costMenuTitle", + ], + "Sources/CodexBar/UsageBreakdownChartMenuView.swift": [ + ".value(\"Day\"", + ".value(\"Credits used\"", + ".value(\"Service\"", + ".value(\"Cap start\"", + ".value(\"Cap end\"", + ], + ] + + var violations: [String] = [] + for (relativePath, markers) in forbiddenMarkersByFile.sorted(by: { $0.key < $1.key }) { + let source = try String(contentsOf: root.appendingPathComponent(relativePath), encoding: .utf8) + for marker in markers where source.contains(marker) { + violations.append("\(relativePath): \(marker)") + } + } + + #expect( + violations.isEmpty, + "Raw user-facing localization markers remain:\n\(violations.joined(separator: "\n"))") + } +} diff --git a/Tests/CodexBarTests/WidgetSnapshotTests.swift b/Tests/CodexBarTests/WidgetSnapshotTests.swift index ea9b84bf..d431e7eb 100644 --- a/Tests/CodexBarTests/WidgetSnapshotTests.swift +++ b/Tests/CodexBarTests/WidgetSnapshotTests.swift @@ -21,7 +21,10 @@ struct WidgetSnapshotTests { sessionCostUSD: 12.3, sessionTokens: 1200, last30DaysCostUSD: 456.7, - last30DaysTokens: 9800), + last30DaysTokens: 9800, + currencyCode: "eur", + sessionLabel: "Latest billing day", + last30DaysLabel: "This month"), dailyUsage: [ WidgetSnapshot.DailyUsagePoint(dayKey: "2025-12-20", totalTokens: 1200, costUSD: 12.3), ]) @@ -42,6 +45,9 @@ struct WidgetSnapshotTests { #expect(decoded.entries.count == 1) #expect(decoded.entries.first?.provider == .codex) #expect(decoded.entries.first?.tokenUsage?.sessionTokens == 1200) + #expect(decoded.entries.first?.tokenUsage?.currencyCode == "EUR") + #expect(decoded.entries.first?.tokenUsage?.sessionLabel == "Latest billing day") + #expect(decoded.entries.first?.tokenUsage?.last30DaysLabel == "This month") #expect(decoded.entries.first?.usageRows?.map(\.id) == ["session", "weekly"]) #expect(decoded.enabledProviders == [.codex, .claude]) } @@ -161,4 +167,40 @@ struct WidgetSnapshotTests { #expect(decoded.entries.first?.usageRows == nil) #expect(decoded.entries.first?.secondary?.usedPercent == 25) } + + @Test + func `widget snapshot decodes legacy token usage as usd`() throws { + let json = """ + { + "entries": [ + { + "provider": "codex", + "updatedAt": "2026-04-04T06:30:00Z", + "primary": null, + "secondary": null, + "tertiary": null, + "creditsRemaining": null, + "codeReviewRemainingPercent": null, + "tokenUsage": { + "sessionCostUSD": 1.25, + "sessionTokens": 1200, + "last30DaysCostUSD": 9.50, + "last30DaysTokens": 4200 + }, + "dailyUsage": [] + } + ], + "generatedAt": "2026-04-04T06:30:00Z" + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(WidgetSnapshot.self, from: Data(json.utf8)) + + #expect(decoded.entries.first?.tokenUsage?.currencyCode == "USD") + #expect(decoded.entries.first?.tokenUsage?.sessionLabel == "Today") + #expect(decoded.entries.first?.tokenUsage?.last30DaysLabel == "30d") + #expect(decoded.enabledProviders == [.codex]) + } } diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj new file mode 100644 index 00000000..195b28ba --- /dev/null +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.pbxproj @@ -0,0 +1,352 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0972D036B563954337344F35 /* CodexBarWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */; }; + 49DB3749D8E8748409CDC4FE /* CodexBarWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */; }; + 6F12082A467310EEDD1F3439 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E430B27E4F28973A5E77EA3F /* WidgetKit.framework */; }; + 7A3A654DC5A5B85C9C41EA02 /* CodexBarCore in Frameworks */ = {isa = PBXBuildFile; productRef = 140C60DAC1DE9A8AE19E58FE /* CodexBarCore */; }; + 7F0E34471853E41206F690FB /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02F15F392AF9D502F0503F8F /* SwiftUI.framework */; }; + 882A41814588292DD631F525 /* CodexBarWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 02F15F392AF9D502F0503F8F /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetBundle.swift; sourceTree = ""; }; + 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetProvider.swift; sourceTree = ""; }; + 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexBarWidgetViews.swift; sourceTree = ""; }; + E430B27E4F28973A5E77EA3F /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CodexBarWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + EFBE36CB6481E7133E2A5CF3 /* CodexBar */ = {isa = PBXFileReference; lastKnownFileType = folder; name = CodexBar; path = ..; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + F5F0F061CD72D02EB841D35C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7A3A654DC5A5B85C9C41EA02 /* CodexBarCore in Frameworks */, + 7F0E34471853E41206F690FB /* SwiftUI.framework in Frameworks */, + 6F12082A467310EEDD1F3439 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4FAD1E2FCD6C4AC65D308ABC /* Packages */ = { + isa = PBXGroup; + children = ( + EFBE36CB6481E7133E2A5CF3 /* CodexBar */, + ); + name = Packages; + sourceTree = ""; + }; + 74E0E4CB8C1E1700BE59E54D = { + isa = PBXGroup; + children = ( + B37422CB8DFAAFC8B3B8C1B6 /* CodexBarWidget */, + 4FAD1E2FCD6C4AC65D308ABC /* Packages */, + CEE79B6AB070A55FA0FB7E12 /* Frameworks */, + B7E03090CEF29F6B74205FAE /* Products */, + ); + sourceTree = ""; + }; + B37422CB8DFAAFC8B3B8C1B6 /* CodexBarWidget */ = { + isa = PBXGroup; + children = ( + 50E5C7D39315A8DA5DC9D18A /* CodexBarWidgetBundle.swift */, + 549C61629C144C190B18EAD9 /* CodexBarWidgetProvider.swift */, + 84672F595D2C0B83323E2C54 /* CodexBarWidgetViews.swift */, + ); + name = CodexBarWidget; + path = ../Sources/CodexBarWidget; + sourceTree = ""; + }; + B7E03090CEF29F6B74205FAE /* Products */ = { + isa = PBXGroup; + children = ( + E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */, + ); + name = Products; + sourceTree = ""; + }; + CEE79B6AB070A55FA0FB7E12 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 02F15F392AF9D502F0503F8F /* SwiftUI.framework */, + E430B27E4F28973A5E77EA3F /* WidgetKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + E9AFBF11687E131ED1AD113A /* CodexBarWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0BDFA2A2ECD62489A63741CF /* Build configuration list for PBXNativeTarget "CodexBarWidgetExtension" */; + buildPhases = ( + 7FB8FE18C057D477EA90DD38 /* Sources */, + F5F0F061CD72D02EB841D35C /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CodexBarWidgetExtension; + packageProductDependencies = ( + 140C60DAC1DE9A8AE19E58FE /* CodexBarCore */, + ); + productName = CodexBarWidgetExtension; + productReference = E7789C4095C40CF60759F2B7 /* CodexBarWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 31A080B4D7A0849832821889 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + }; + }; + buildConfigurationList = A17A8A4315AD7DBD4D417FCA /* Build configuration list for PBXProject "CodexBarWidgetExtension" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 74E0E4CB8C1E1700BE59E54D; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + B0BE8F0E2917CB6B2EDF1826 /* XCLocalSwiftPackageReference ".." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = B7E03090CEF29F6B74205FAE /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + E9AFBF11687E131ED1AD113A /* CodexBarWidgetExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 7FB8FE18C057D477EA90DD38 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0972D036B563954337344F35 /* CodexBarWidgetBundle.swift in Sources */, + 49DB3749D8E8748409CDC4FE /* CodexBarWidgetProvider.swift in Sources */, + 882A41814588292DD631F525 /* CodexBarWidgetViews.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 4A864C1BFFF710E1A519CCF5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 76EC15BB9FE2307D815E380E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 7B3A3941F0FAA0B4ADAB8760 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = NO; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_APP_SANDBOX = YES; + ENABLE_DEBUG_DYLIB = NO; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CodexBarTeamID = "$(CODEXBAR_TEAM_ID)"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(CODEXBAR_WIDGET_BUNDLE_ID)"; + PRODUCT_NAME = CodexBarWidget; + SDKROOT = macosx; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 88E6F58603FDA13ED00BF91D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGNING_ALLOWED = NO; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_APP_SANDBOX = YES; + ENABLE_DEBUG_DYLIB = NO; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CodexBarTeamID = "$(CODEXBAR_TEAM_ID)"; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = "$(CODEXBAR_WIDGET_BUNDLE_ID)"; + PRODUCT_NAME = CodexBarWidget; + SDKROOT = macosx; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0BDFA2A2ECD62489A63741CF /* Build configuration list for PBXNativeTarget "CodexBarWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7B3A3941F0FAA0B4ADAB8760 /* Debug */, + 88E6F58603FDA13ED00BF91D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + A17A8A4315AD7DBD4D417FCA /* Build configuration list for PBXProject "CodexBarWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4A864C1BFFF710E1A519CCF5 /* Debug */, + 76EC15BB9FE2307D815E380E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + B0BE8F0E2917CB6B2EDF1826 /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 140C60DAC1DE9A8AE19E58FE /* CodexBarCore */ = { + isa = XCSwiftPackageProductDependency; + productName = CodexBarCore; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 31A080B4D7A0849832821889 /* Project object */; +} diff --git a/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/WidgetExtension/CodexBarWidgetExtension.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/WidgetExtension/Info.plist b/WidgetExtension/Info.plist new file mode 100644 index 00000000..bf7636ef --- /dev/null +++ b/WidgetExtension/Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + CodexBar + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + CodexBarWidget + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CodexBarTeamID + $(CODEXBAR_TEAM_ID) + LSMinimumSystemVersion + 14.0 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/WidgetExtension/project.yml b/WidgetExtension/project.yml new file mode 100644 index 00000000..269c830c --- /dev/null +++ b/WidgetExtension/project.yml @@ -0,0 +1,42 @@ +name: CodexBarWidgetExtension +options: + deploymentTarget: + macOS: "14.0" +packages: + CodexBar: + path: .. +targets: + CodexBarWidgetExtension: + type: app-extension + platform: macOS + deploymentTarget: "14.0" + sources: + - path: ../Sources/CodexBarWidget + dependencies: + - package: CodexBar + product: CodexBarCore + - sdk: SwiftUI.framework + - sdk: WidgetKit.framework + info: + path: Info.plist + properties: + CFBundleDisplayName: CodexBar + CFBundleName: CodexBarWidget + CFBundlePackageType: XPC! + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + CodexBarTeamID: "$(CODEXBAR_TEAM_ID)" + LSMinimumSystemVersion: "14.0" + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + settings: + base: + APPLICATION_EXTENSION_API_ONLY: true + CODE_SIGNING_ALLOWED: false + ENABLE_DEBUG_DYLIB: false + ENABLE_APP_SANDBOX: true + INFOPLIST_KEY_CodexBarTeamID: "$(CODEXBAR_TEAM_ID)" + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks" + PRODUCT_BUNDLE_IDENTIFIER: "$(CODEXBAR_WIDGET_BUNDLE_ID)" + PRODUCT_NAME: CodexBarWidget + SWIFT_VERSION: "6.0" diff --git a/bin/install-codexbar-cli.sh b/bin/install-codexbar-cli.sh index 50fb9533..742a974e 100755 --- a/bin/install-codexbar-cli.sh +++ b/bin/install-codexbar-cli.sh @@ -10,23 +10,20 @@ if [[ ! -x "$HELPER" ]]; then exit 1 fi -install_script=$(mktemp) -cat > "$install_script" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -HELPER="__HELPER__" -TARGETS=("/usr/local/bin/codexbar" "/opt/homebrew/bin/codexbar") - -for t in "${TARGETS[@]}"; do - mkdir -p "$(dirname "$t")" - ln -sf "$HELPER" "$t" - echo "Linked $t -> $HELPER" -done -EOF - -perl -pi -e "s#__HELPER__#$HELPER#g" "$install_script" +osascript - "$HELPER" <<'APPLESCRIPT' +on run argv + set helperPath to item 1 of argv + set installCommand to "set -euo pipefail" & linefeed & ¬ + "HELPER=" & quoted form of helperPath & linefeed & ¬ + "TARGETS=(\"/usr/local/bin/codexbar\" \"/opt/homebrew/bin/codexbar\")" & linefeed & ¬ + "for t in \"${TARGETS[@]}\"; do" & linefeed & ¬ + " mkdir -p \"$(dirname \"$t\")\"" & linefeed & ¬ + " ln -sf \"$HELPER\" \"$t\"" & linefeed & ¬ + " echo \"Linked $t -> $HELPER\"" & linefeed & ¬ + "done" -osascript -e "do shell script \"bash '$install_script'\" with administrator privileges" -rm -f "$install_script" + do shell script "bash -c " & quoted form of installCommand with administrator privileges +end run +APPLESCRIPT echo "CodexBar CLI installed. Try: codexbar usage" diff --git a/docs/alibaba-token-plan.md b/docs/alibaba-token-plan.md new file mode 100644 index 00000000..1d888460 --- /dev/null +++ b/docs/alibaba-token-plan.md @@ -0,0 +1,61 @@ +--- +summary: "Alibaba Token Plan provider notes: Bailian cookie auth, subscription summary endpoint, and setup." +read_when: + - Adding or modifying the Alibaba Token Plan provider + - Debugging Alibaba Token Plan cookie import or subscription summary fetching + - Explaining Alibaba Token Plan setup and limitations to users +--- + +# Alibaba Token Plan Provider + +The Alibaba Token Plan provider tracks Bailian token-plan credits from the Alibaba Cloud console. + +## Features + +- **Token-plan usage display**: Shows used, total, and remaining token-plan credits when Bailian returns quota totals. +- **Cookie-based auth**: Uses browser cookies or a pasted `Cookie:` header. +- **Expiry awareness**: Shows the nearest token-plan expiration date as the reset time when the subscription summary includes it. + +## Setup + +1. Open **Settings -> Providers** +2. Enable **Alibaba Token Plan** +3. Leave **Cookie source** on **Auto** (recommended) + +### Manual cookie import (optional) + +1. Open `https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan` +2. Copy a `Cookie:` header from your browser's Network tab +3. Paste it into **Alibaba Token Plan -> Cookie source -> Manual** + +## How it works + +- Fetches `POST https://bailian.console.aliyun.com/data/api.json?action=GetSubscriptionSummary&product=BssOpenAPI-V3&_tag=` +- Sends form-encoded fields for `product=BssOpenAPI-V3`, `action=GetSubscriptionSummary`, `region=cn-beijing`, and `params={"ProductCode":"sfm_tokenplanteams_dp_cn"}` +- Uses Alibaba/Bailian login cookies, with `sec_token` added when it can be resolved from the dashboard page +- Parses `TotalValue`, `TotalSurplusValue`, `TotalCount`, and `NearestExpireDate` from the subscription summary response +- Supports `ALIBABA_TOKEN_PLAN_HOST` and `ALIBABA_TOKEN_PLAN_QUOTA_URL` for testing endpoint overrides + +## Limitations + +- Alibaba Token Plan currently supports the Bailian web-cookie path only +- API-key auth, token cost summaries, and automatic status polling are not supported +- The default endpoint is the China mainland Bailian token-plan subscription summary + +## Troubleshooting + +### "No Alibaba Token Plan session cookies found in browsers" + +Log in at `https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan` in Chrome, then refresh CodexBar. + +### "Alibaba Token Plan cookie header is invalid" + +The pasted header is empty or not a valid Cookie header. Re-copy the request from the Token Plan page after logging in again. + +### "Alibaba Token Plan login required" + +Your Bailian session is stale. Sign out and back in on the Bailian console, then refresh CodexBar. + +### Empty subscription summary + +If Bailian returns `TotalCount: 0`, CodexBar keeps the provider visible but does not show a quota window because the account has no active token-plan subscription summary to graph. diff --git a/docs/bedrock.md b/docs/bedrock.md index 7a8237f6..6cb6d7fa 100644 --- a/docs/bedrock.md +++ b/docs/bedrock.md @@ -10,9 +10,13 @@ read_when: CodexBar reads AWS Cost Explorer for Bedrock spend and can compare the current month against an optional budget. -## Setup +## Authentication -Provide AWS credentials through the environment inherited by CodexBar or the CLI: +CodexBar supports two authentication modes, selected in Preferences → Providers → AWS Bedrock → Authentication. + +### Access keys (default) + +Provide static AWS credentials through Settings or the environment inherited by CodexBar/the CLI: ```bash export AWS_ACCESS_KEY_ID="..." @@ -27,7 +31,32 @@ export AWS_SESSION_TOKEN="..." export CODEXBAR_BEDROCK_BUDGET="250" ``` -The AWS identity must have permission to call Cost Explorer APIs, including `ce:GetCostAndUsage`. +### AWS profile + +Resolve credentials from a named profile in `~/.aws/config` / `~/.aws/credentials` instead of pasting keys. Set the +profile name in Settings (or via `AWS_PROFILE`). CodexBar shells out to the AWS CLI +(`aws configure export-credentials --profile `), so this works with **SSO**, **assume-role**, +`credential_process`, and MFA-cached profiles — not just static credentials. + +Requirements: + +- AWS CLI v2 on your `PATH` (CodexBar also checks `/opt/homebrew/bin/aws`, `/usr/local/bin/aws`, and `~/.local/bin/aws`). + Override the location with `AWS_CLI_PATH` if it lives elsewhere. +- For SSO profiles, an active session (`aws sso login --profile `). Credentials are resolved fresh on each + refresh; the AWS CLI caches the SSO token, so this does not re-prompt unless the session has expired. + +The profile's region is read automatically (`aws configure get region`); leave the Region field blank to use it, or set +`AWS_REGION` / the Region field to override. + +Relevant environment variables: + +```bash +export CODEXBAR_BEDROCK_AUTH_MODE="profile" # set automatically by Settings; "keys" or "profile" +export AWS_PROFILE="work" +export AWS_CLI_PATH="/opt/homebrew/bin/aws" # optional override +``` + +The AWS identity (from either mode) must have permission to call Cost Explorer APIs, including `ce:GetCostAndUsage`. ## Data source @@ -58,6 +87,17 @@ codexbar --provider bedrock --format json --pretty - Confirm the AWS account has Cost Explorer enabled. - Confirm the IAM principal can call `ce:GetCostAndUsage`. - If using temporary credentials, include `AWS_SESSION_TOKEN`. +- In profile mode, confirm the AWS CLI is installed (or set `AWS_CLI_PATH`) and that the profile name is correct. + +### "AWS profile session expired" + +The profile's SSO/temporary session has expired. Run `aws sso login --profile ` (or refresh the underlying +credentials) and retry. + +### "AWS CLI not found" + +Profile mode requires AWS CLI v2. Install it (e.g. `brew install awscli`) or point CodexBar at the binary with +`AWS_CLI_PATH`. ### Wrong region @@ -67,5 +107,6 @@ Set `AWS_REGION` or `AWS_DEFAULT_REGION`. Bedrock usage is regional, but Cost Ex - `Sources/CodexBarCore/Providers/Bedrock/BedrockProviderDescriptor.swift` - `Sources/CodexBarCore/Providers/Bedrock/BedrockSettingsReader.swift` +- `Sources/CodexBarCore/Providers/Bedrock/BedrockProfileCredentialProvider.swift` - `Sources/CodexBarCore/Providers/Bedrock/BedrockUsageStats.swift` - `Sources/CodexBarCore/Providers/Bedrock/BedrockAWSSigner.swift` diff --git a/docs/claude.md b/docs/claude.md index bb03117f..ecafe178 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -75,6 +75,8 @@ Admin API key setup: - `five_hour` → session window. - `seven_day` → weekly window; also becomes the primary fallback when `five_hour` is absent or has no utilization. - `seven_day_sonnet` / `seven_day_opus` → model-specific weekly window. + - `seven_day_routines` / `seven_day_cowork` → Daily Routines extra window. + - Claude Design/Omelette keys are ignored because Claude Design shares the main Claude usage limit. - `extra_usage` → Extra usage cost (monthly spend/limit). - Successful OAuth login enables Claude and selects OAuth as the usage source. - Plan inference: `subscriptionType` is preferred when present; `rate_limit_tier` falls back to @@ -105,6 +107,7 @@ Admin API key setup: - `GET https://claude.ai/api/account` → email + plan hints. - Outputs: - Session + weekly + model-specific percent used. + - Daily Routines extra window when returned by the usage API. - Extra usage spend/limit (if enabled). - Account email + inferred plan. diff --git a/docs/cli.md b/docs/cli.md index c9a01015..9bdb386b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -47,6 +47,7 @@ See `docs/configuration.md` for the schema. - `codexbar serve` starts a foreground localhost-only HTTP server for usage and cost JSON. - `--port ` defaults to `8080`. - `--refresh-interval ` defaults to `60` and controls the in-memory response cache TTL. + - `--request-timeout ` defaults to `30` and bounds each request before returning `504 Gateway Timeout`; use `0` to keep waiting indefinitely. - v1 binds to `127.0.0.1` only and rejects non-loopback `Host` headers. It does not expose remote bind, auth, CORS, TLS, or daemon mode. - Endpoints: `GET /health`, `GET /usage`, `GET /usage?provider=`, `GET /cost`, `GET /cost?provider=`. - Codex usage responses include every visible Codex account, matching the menu bar switcher. @@ -120,6 +121,7 @@ codexbar cost # local cost usage (default 30-day window + to codexbar cost --days 90 # choose a 1...365 day cost window codexbar cost --provider claude --format json --pretty codexbar serve --port 8080 # localhost HTTP JSON server +codexbar serve --request-timeout 0 # disable serve request deadlines COPILOT_API_TOKEN=... codexbar --provider copilot --format json --pretty codexbar --status # include status page indicator/description codexbar --provider codex --source oauth --format json --pretty diff --git a/docs/codex.md b/docs/codex.md index 31216328..7dfeb224 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -31,6 +31,10 @@ Usage source picker: - Reads OAuth tokens from `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`). - Refreshes access tokens when `last_refresh` is older than 8 days. - Calls `GET https://chatgpt.com/backend-api/wham/usage` (default) with `Authorization: Bearer `. +- `rate_limit.primary_window` / `secondary_window` map to the session/weekly lanes. +- `additional_rate_limits[]` (model-specific limits such as GPT-5.3-Codex-Spark) map to named + `UsageSnapshot.extraRateWindows` entries (Spark uses a stable `codex-spark` id / `Codex Spark` title). + When the field is absent, the snapshot is unchanged. ### OpenAI web dashboard (optional, off by default) - Enable it in Preferences -> Providers -> Codex -> OpenAI web extras. diff --git a/docs/configuration.md b/docs/configuration.md index 763ffa75..8d463652 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -53,7 +53,8 @@ All provider fields are optional unless noted. - `auto` (browser import), `manual` (use `cookieHeader`), `off` (disable cookies) - `cookieHeader`: raw cookie header value (e.g. `key=value; other=...`). - `region`: provider-specific region (e.g. `zai`, `minimax`). -- `workspaceID`: provider-specific workspace/deployment ID (e.g. Azure OpenAI deployment, `opencode`). +- `workspaceID`: provider-specific workspace/deployment/project ID (e.g. Azure OpenAI deployment, OpenAI API project, + `opencode`). - `tokenAccounts`: multi-account tokens for providers in `TokenAccountSupportCatalog`. ## Manual cookies @@ -104,6 +105,18 @@ printf '%s' "$GROQ_API_KEY" | codexbar config set-api-key --provider groq --stdi printf '%s' "$LLM_PROXY_API_KEY" | codexbar config set-api-key --provider llmproxy --stdin ``` +OpenAI API project scoping uses `workspaceID` in config. This maps to `OPENAI_PROJECT_ID` for Admin API usage and is +only applied to the configured OpenAI key, not to selected OpenAI token accounts: + +```json +{ + "id": "openai", + "enabled": true, + "apiKey": "", + "workspaceID": "proj_..." +} +``` + LLM Proxy also needs a base URL. Set `enterpriseHost` in config or `LLM_PROXY_BASE_URL` in the process environment: ```json diff --git a/docs/grok.md b/docs/grok.md index 665ed401..37da32c9 100644 --- a/docs/grok.md +++ b/docs/grok.md @@ -40,7 +40,7 @@ browser session when the CLI surface does not expose billing. - `~/.grok/auth.json` is still used for identity and as a last best-effort bearer probe, but the production grok.com billing endpoint currently authenticates browser sessions. - - Parses the returned protobuf enough to recover monthly used percent and + - Parses the returned protobuf enough to recover used percent and reset timestamp. This keeps billing visible when `grok agent stdio` returns `Method not found`. 4) **Local session signals** (informational fallback) @@ -97,11 +97,15 @@ browser session when the CLI surface does not expose billing. ## Mapping to `UsageSnapshot` -- **Primary window** = monthly credit usage: +- **Primary window** = credit usage (against the subscription/included limit): - CLI RPC: `usedPercent` = `usage.totalUsed.val / monthlyLimit.val * 100`; `resetsAt` = `billingCycle.billingPeriodEnd`. - grok.com fallback: `usedPercent` and `resetsAt` parsed from the gRPC-web billing protobuf. + - The UI label for the live usage bar is dynamic: "Weekly" or "Monthly" + when `resetsAt` matches a common cycle, falling back to the registered + "Credits" label otherwise. Settings and history views continue to use + "Credits" as the stable metric name. - **Identity**: - `accountEmail` from credential `email`. - `accountOrganization` from credential `team_id`. diff --git a/docs/minimax.md b/docs/minimax.md index aa427bcd..15bb3ecb 100644 --- a/docs/minimax.md +++ b/docs/minimax.md @@ -60,3 +60,32 @@ quota card and omits the chart instead of treating the whole provider as failed. - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxUsageFetcher.swift` - `Sources/CodexBarCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift` - `Sources/CodexBar/Providers/MiniMax/MiniMaxProviderImplementation.swift` + +## CLI diagnose command + +The generic `diagnose` command performs a real provider diagnostic invocation and emits a safe, redacted JSON export +for issue reporting and verification. MiniMax adds a provider-specific `details` block with safe usage metadata. + +### Usage +``` +codexbar diagnose --provider minimax --format json --pretty +``` + +### Output +- Structural diagnostic JSON with provider, source/source mode, auth summary, usage summary, fetch attempts, and error categories. +- All sensitive fields (API tokens, cookies, emails, auth headers) are redacted via `LogRedactor`. +- Errors are mapped to safe categories (`network`, `auth`, `api`, `parse`) with user-friendly descriptions. +- No raw API responses, raw error messages, tokens, cookies, emails, account IDs, org IDs, or billing history. + +### What is excluded from output +- Raw API tokens (`sk-cp-*`, `sk-api-*`) and authorization headers +- Cookie header values +- Email addresses +- Account IDs, org IDs +- Raw error messages (replaced with safe category-based descriptions) +- Raw HTTP responses or request bodies +- Billing history details + +### Exit codes +- `0`: Diagnostic completed successfully (even if provider auth is not configured) +- `1`: Unknown error or invalid arguments diff --git a/docs/openai.md b/docs/openai.md index d27dc907..6db22965 100644 --- a/docs/openai.md +++ b/docs/openai.md @@ -15,6 +15,8 @@ CodexBar's OpenAI API provider targets the API Platform organization dashboard, - `GET https://api.openai.com/v1/organization/costs` - `GET https://api.openai.com/v1/organization/usage/completions` - Daily buckets use `bucket_width=1d`, costs are grouped by `line_item`, and completion usage is grouped by `model`. + - Optional project scoping comes from `OPENAI_PROJECT_ID` or `providers[].workspaceID` for `openai`. + Project-scoped requests add `project_ids=` to both Admin API endpoints. 2. Fallback: legacy `GET https://api.openai.com/v1/dashboard/billing/credit_grants` for normal API keys that cannot access organization usage. ## Setup @@ -29,12 +31,29 @@ Settings → Providers → OpenAI writes the same `~/.codexbar/config.json` fiel `OPENAI_API_KEY` because it unlocks organization costs and usage; a normal API key only supports the legacy balance fallback. +To scope Admin API usage to a project, set the OpenAI Project ID field in Settings or add `workspaceID` to the `openai` +provider config: + +```json +{ + "id": "openai", + "apiKey": "", + "workspaceID": "proj_..." +} +``` + +Project scoping is tied to the configured Admin API key. Selected OpenAI token accounts intentionally scrub +`OPENAI_PROJECT_ID`/`workspaceID` so one account cannot inherit another account's project filter. Project-scoped Admin +API failures do not fall back to the legacy billing endpoint, because that endpoint is not project-filtered. + ## Menu display - Admin API data renders inline Today/7d/configured-window KPIs plus a compact spend chart. - The inline usage card opens a hosted chart submenu with daily spend, token, and request trends plus selected-day detail. - Top model and top spend labels come from the configured completion/cost buckets when the Admin API returns them. - Legacy balance data keeps the older available/used credit summary and does not show organization graphs. +- Project-scoped Admin API data labels the account as `Admin API: ` and the organization line as + `Project: `. ## Notes diff --git a/docs/packaging.md b/docs/packaging.md index fd9eb132..16e99815 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -15,7 +15,7 @@ read_when: - `Scripts/changelog-to-html.sh`: converts the per-version changelog section to HTML for Sparkle. ## Bundle contents -- `CodexBarWidget.appex` bundled with app-group entitlements. +- `CodexBarWidget.appex` is built by `WidgetExtension/CodexBarWidgetExtension.xcodeproj` as a real macOS app extension, then bundled with app-group entitlements. - `CodexBarCLI` copied to `CodexBar.app/Contents/Helpers/` for symlinking. - SwiftPM resource bundles (e.g. `KeyboardShortcuts_KeyboardShortcuts.bundle`) copied into `Contents/Resources` (required for `KeyboardShortcuts.Recorder`). diff --git a/docs/providers.md b/docs/providers.md index 7f1d0f6b..18f52352 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 47 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +CodexBar currently registers 48 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current) @@ -31,6 +31,7 @@ headers, source selection, provider ordering, and token accounts are stored in ` | OpenCode | Web dashboard via cookies (`web`). | | OpenCode Go | Web dashboard via cookies (`web`); optional workspace ID. | | Alibaba Coding Plan | Console RPC via web cookies (auto/manual) with API key fallback (`web`, `api`). | +| Alibaba Token Plan | Bailian subscription summary API via browser or manual cookies (`web`). | | Droid/Factory | Web cookies → stored tokens → local storage → WorkOS cookies (`web`). | | z.ai | API token from config/env → quota API (`api`). | | Manus | Browser `session_id` cookie (auto/manual/env) → credits API (`web`). | @@ -179,6 +180,14 @@ headers, source selection, provider ordering, and token accounts are stored in ` - Status: `https://status.aliyun.com` (link only, no auto-polling). - Details: `docs/alibaba-coding-plan.md`. +## Alibaba Token Plan +- Web mode posts to the Bailian `GetSubscriptionSummary` endpoint with form-encoded params and optional `sec_token`. +- Cookie sources: browser import (`auto`), manual Cookie header, or `ALIBABA_TOKEN_PLAN_COOKIE`. +- Default quota URL: `https://bailian.console.aliyun.com/data/api.json?action=GetSubscriptionSummary&product=BssOpenAPI-V3`. +- Host overrides: `ALIBABA_TOKEN_PLAN_HOST` or `ALIBABA_TOKEN_PLAN_QUOTA_URL`. +- Status: `https://status.aliyun.com` (link only, no auto-polling). +- Details: `docs/alibaba-token-plan.md`. + ## Droid (Factory) - Web API via Factory cookies, bearer tokens, and WorkOS refresh tokens. - Multiple fallback strategies (cookies → stored tokens → local storage → WorkOS cookies). diff --git a/docs/widgets.md b/docs/widgets.md index 2123c129..bb916e3b 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -16,6 +16,7 @@ read_when: ## Extension - `Sources/CodexBarWidget` contains timeline + views. +- `WidgetExtension/CodexBarWidgetExtension.xcodeproj` builds those sources as the packaged macOS WidgetKit app extension. - Keep data shape in sync with `WidgetSnapshot` in the main app. ## Widget types diff --git a/version.env b/version.env index 857b9250..3eadc2b7 100644 --- a/version.env +++ b/version.env @@ -1,8 +1,7 @@ -MARKETING_VERSION=0.29.0.1 -BUILD_NUMBER=68.1 +MARKETING_VERSION=0.32.1.1 +BUILD_NUMBER=76.1 MOBILE_VERSION=1.9.0 -# Last upstream tag confirmed shipped to users. Lags MARKETING_VERSION -# until the corresponding release reaches end users (Sparkle / App Store). -# Bump this after the merged version is actually live, not at merge time. -UPSTREAM_VERSION=v0.29.0 -UPSTREAM_SYNC_DATE=2026-05-25 +# Last upstream tag merged into this fork. Release publication is tracked by +# the Sparkle appcast and GitHub release tag. +UPSTREAM_VERSION=v0.32.1 +UPSTREAM_SYNC_DATE=2026-05-31